dev-loops 0.1.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.
Files changed (156) hide show
  1. package/.pi/dev-loop/defaults.yaml +477 -0
  2. package/AGENTS.md +25 -0
  3. package/CHANGELOG.md +18 -0
  4. package/LICENSE +21 -0
  5. package/README.md +178 -0
  6. package/agents/dev-loop.agent.md +82 -0
  7. package/agents/developer.agent.md +37 -0
  8. package/agents/docs.agent.md +33 -0
  9. package/agents/fixer.agent.md +53 -0
  10. package/agents/quality.agent.md +28 -0
  11. package/agents/refiner.agent.md +87 -0
  12. package/agents/review.agent.md +64 -0
  13. package/cli/index.mjs +424 -0
  14. package/extension/README.md +233 -0
  15. package/extension/checks.ts +94 -0
  16. package/extension/index.ts +131 -0
  17. package/extension/post-merge-update.ts +512 -0
  18. package/extension/presentation.ts +107 -0
  19. package/lib/dev-loops-core.mjs +284 -0
  20. package/package.json +103 -0
  21. package/scripts/README.md +1007 -0
  22. package/scripts/_cli-primitives.mjs +10 -0
  23. package/scripts/_core-helpers.mjs +30 -0
  24. package/scripts/docs/validate-links.mjs +567 -0
  25. package/scripts/docs/validate-no-duplicate-rules.mjs +250 -0
  26. package/scripts/github/_review-thread-mutations.mjs +214 -0
  27. package/scripts/github/capture-review-threads.mjs +180 -0
  28. package/scripts/github/create-draft-pr.mjs +108 -0
  29. package/scripts/github/detect-checkpoint-evidence.mjs +393 -0
  30. package/scripts/github/detect-linked-issue-pr.mjs +331 -0
  31. package/scripts/github/manage-sub-issues.mjs +394 -0
  32. package/scripts/github/probe-copilot-review.mjs +323 -0
  33. package/scripts/github/ready-for-review.mjs +93 -0
  34. package/scripts/github/reconcile-draft-gate.mjs +328 -0
  35. package/scripts/github/reply-resolve-review-thread.mjs +42 -0
  36. package/scripts/github/reply-resolve-review-threads.mjs +329 -0
  37. package/scripts/github/request-copilot-review.mjs +551 -0
  38. package/scripts/github/resolve-tracker-local-spec.mjs +205 -0
  39. package/scripts/github/stage-reviewer-draft.mjs +191 -0
  40. package/scripts/github/upsert-checkpoint-verdict.mjs +694 -0
  41. package/scripts/github/verify-fresh-review-context.mjs +125 -0
  42. package/scripts/github/write-gate-findings-log.mjs +212 -0
  43. package/scripts/loop/_checkpoint-io.mjs +55 -0
  44. package/scripts/loop/_checkpoint-paths.mjs +28 -0
  45. package/scripts/loop/_handoff-contract.mjs +230 -0
  46. package/scripts/loop/_inspect-run-viewer-adapter.mjs +345 -0
  47. package/scripts/loop/_loop-evidence.mjs +32 -0
  48. package/scripts/loop/_pr-runner-coordination.mjs +611 -0
  49. package/scripts/loop/_stale-runner-detection.mjs +145 -0
  50. package/scripts/loop/_steering-state-file.mjs +134 -0
  51. package/scripts/loop/build-handoff-envelope.mjs +181 -0
  52. package/scripts/loop/checkpoint-contract.mjs +49 -0
  53. package/scripts/loop/conductor-monitor.mjs +1850 -0
  54. package/scripts/loop/conductor.mjs +214 -0
  55. package/scripts/loop/copilot-pr-handoff.mjs +493 -0
  56. package/scripts/loop/debt-remediate.mjs +304 -0
  57. package/scripts/loop/detect-change-scope.mjs +102 -0
  58. package/scripts/loop/detect-copilot-loop-state.mjs +454 -0
  59. package/scripts/loop/detect-copilot-session-activity.mjs +186 -0
  60. package/scripts/loop/detect-initial-copilot-pr-state.mjs +318 -0
  61. package/scripts/loop/detect-internal-only-pr.mjs +270 -0
  62. package/scripts/loop/detect-issue-refinement-artifact.mjs +163 -0
  63. package/scripts/loop/detect-pr-gate-coordination-state.mjs +509 -0
  64. package/scripts/loop/detect-reviewer-loop-state.mjs +231 -0
  65. package/scripts/loop/detect-stale-runner.mjs +250 -0
  66. package/scripts/loop/detect-tracker-first-loop-state.mjs +76 -0
  67. package/scripts/loop/detect-tracker-pr-state.mjs +102 -0
  68. package/scripts/loop/info.mjs +267 -0
  69. package/scripts/loop/inspect-run-viewer/cli.mjs +117 -0
  70. package/scripts/loop/inspect-run-viewer/constants.mjs +80 -0
  71. package/scripts/loop/inspect-run-viewer/graph.mjs +757 -0
  72. package/scripts/loop/inspect-run-viewer/handoff-envelope-renderer.mjs +398 -0
  73. package/scripts/loop/inspect-run-viewer/inbox.mjs +308 -0
  74. package/scripts/loop/inspect-run-viewer/managed-instance.mjs +750 -0
  75. package/scripts/loop/inspect-run-viewer/rendering.mjs +411 -0
  76. package/scripts/loop/inspect-run-viewer/server.mjs +638 -0
  77. package/scripts/loop/inspect-run-viewer/shared.mjs +103 -0
  78. package/scripts/loop/inspect-run-viewer/status.mjs +715 -0
  79. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +77 -0
  80. package/scripts/loop/inspect-run-viewer.mjs +82 -0
  81. package/scripts/loop/inspect-run.mjs +382 -0
  82. package/scripts/loop/outer-loop.mjs +419 -0
  83. package/scripts/loop/pr-runner-coordination.mjs +143 -0
  84. package/scripts/loop/pre-commit-branch-guard.mjs +68 -0
  85. package/scripts/loop/pre-flight-gate.mjs +236 -0
  86. package/scripts/loop/pre-pr-ready-gate.mjs +183 -0
  87. package/scripts/loop/pre-push-main-guard.mjs +103 -0
  88. package/scripts/loop/pre-write-remote-freshness-guard.mjs +32 -0
  89. package/scripts/loop/print-gates.mjs +42 -0
  90. package/scripts/loop/resolve-dev-loop-startup.mjs +533 -0
  91. package/scripts/loop/run-conductor-cycle.mjs +322 -0
  92. package/scripts/loop/run-queue.mjs +124 -0
  93. package/scripts/loop/run-refinement-audit.mjs +513 -0
  94. package/scripts/loop/run-watch-cycle.mjs +358 -0
  95. package/scripts/loop/steer-loop.mjs +841 -0
  96. package/scripts/loop/ui-designer-review-contract.mjs +76 -0
  97. package/scripts/loop/watch-initial-copilot-pr.mjs +253 -0
  98. package/scripts/projects/add-queue-item.mjs +528 -0
  99. package/scripts/projects/ensure-queue-board.mjs +837 -0
  100. package/scripts/projects/list-queue-items.mjs +489 -0
  101. package/scripts/projects/move-queue-item.mjs +549 -0
  102. package/scripts/projects/reorder-queue-item.mjs +518 -0
  103. package/scripts/refine/_refine-helpers.mjs +258 -0
  104. package/scripts/refine/prose-linkage-detector.mjs +92 -0
  105. package/scripts/refine/refinement-completeness-checker.mjs +88 -0
  106. package/scripts/refine/scope-boundary-cross-checker.mjs +163 -0
  107. package/scripts/refine/tree-integrity-validator.mjs +211 -0
  108. package/scripts/refine/verify.mjs +178 -0
  109. package/scripts/repo-wiki-local.mjs +156 -0
  110. package/scripts/repo-wiki.mjs +119 -0
  111. package/skills/copilot-pr-followup/SKILL.md +380 -0
  112. package/skills/dev-loop/SKILL.md +141 -0
  113. package/skills/dev-loop/scripts/dev-mode-context.mjs +152 -0
  114. package/skills/dev-loop/scripts/dev-mode-context.test.mjs +80 -0
  115. package/skills/dev-loop/scripts/init-phase.mjs +71 -0
  116. package/skills/dev-loop/scripts/log-bash-exit-1.mjs +25 -0
  117. package/skills/dev-loop/scripts/phase-files.mjs +29 -0
  118. package/skills/dev-loop/scripts/post-gate-verdict-fallback.mjs +480 -0
  119. package/skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs +732 -0
  120. package/skills/dev-loop/scripts/render-template.mjs +82 -0
  121. package/skills/dev-loop/scripts/render-template.test.mjs +63 -0
  122. package/skills/dev-loop/templates/bootstrap-agents.md +26 -0
  123. package/skills/dev-loop/templates/bootstrap-implementation-state.md +31 -0
  124. package/skills/dev-loop/templates/bootstrap-implementation-workflow.md +17 -0
  125. package/skills/dev-loop/templates/dev-mode-retrospective.md +15 -0
  126. package/skills/dev-loop/templates/dev-mode-review.md +17 -0
  127. package/skills/dev-loop/templates/dev-mode-skill-changes.md +11 -0
  128. package/skills/dev-loop/templates/merged-phase-plan.md +19 -0
  129. package/skills/dev-loop/templates/phase-doc.md +27 -0
  130. package/skills/dev-loop/templates/phase-summary.md +13 -0
  131. package/skills/dev-loop/templates/phase-variant.md +15 -0
  132. package/skills/dev-loop/templates/retrospective.md +11 -0
  133. package/skills/dev-loop/templates/review.md +32 -0
  134. package/skills/dev-loop/templates/ui-vision-review.md +55 -0
  135. package/skills/docs/acceptance-criteria-verification.md +21 -0
  136. package/skills/docs/anti-patterns.md +21 -0
  137. package/skills/docs/artifact-authority-contract.md +119 -0
  138. package/skills/docs/confirmation-rules.md +28 -0
  139. package/skills/docs/copilot-ci-status-contract.md +52 -0
  140. package/skills/docs/copilot-loop-operations.md +233 -0
  141. package/skills/docs/debt-remediation-contract.md +107 -0
  142. package/skills/docs/entrypoint-strategies.md +115 -0
  143. package/skills/docs/epic-tree-refinement-procedure.md +234 -0
  144. package/skills/docs/issue-intake-procedure.md +235 -0
  145. package/skills/docs/main-agent-contract.md +72 -0
  146. package/skills/docs/merge-preconditions.md +29 -0
  147. package/skills/docs/pr-lifecycle-contract.md +209 -0
  148. package/skills/docs/public-dev-loop-contract.md +497 -0
  149. package/skills/docs/retrospective-checkpoint-contract.md +159 -0
  150. package/skills/docs/stop-conditions.md +29 -0
  151. package/skills/docs/structural-quality.md +42 -0
  152. package/skills/docs/tracker-first-loop-state.md +281 -0
  153. package/skills/docs/validation-policy.md +27 -0
  154. package/skills/docs/workflow-handoff-contract.md +135 -0
  155. package/skills/final-approval/SKILL.md +19 -0
  156. package/skills/local-implementation/SKILL.md +640 -0
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
+ import { parseIssueNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
4
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
5
+ import { detectLinkedIssuePr } from "../github/detect-linked-issue-pr.mjs";
6
+ import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
7
+ const USAGE = `Usage: detect-initial-copilot-pr-state.mjs --repo <owner/name> --issue <number>
8
+ Detect whether an assigned issue is still on the bootstrap-only Copilot draft PR
9
+ or has moved into normal linked-PR follow-up.
10
+ Required:
11
+ --repo <owner/name> Repository slug (e.g. owner/repo)
12
+ --issue <number> Issue number
13
+ States:
14
+ no_linked_pr
15
+ prior_linked_pr_closed_unmerged
16
+ copilot_session_active
17
+ waiting_for_initial_copilot_implementation
18
+ linked_pr_ready_for_followup
19
+ Success output (stdout, JSON):
20
+ {
21
+ "ok": true,
22
+ "repo": "owner/name",
23
+ "issue": 59,
24
+ "state": "no_linked_pr"|"prior_linked_pr_closed_unmerged"|"copilot_session_active"|"waiting_for_initial_copilot_implementation"|"linked_pr_ready_for_followup",
25
+ "prNumber": 79|null,
26
+ "prUrl": "..."|null,
27
+ "headBranch": "..."|null,
28
+ "authorLogin": "Copilot"|null,
29
+ "isDraft": true|false|null,
30
+ "changedFiles": 0|null,
31
+ "commitCount": 1|null,
32
+ "soleCommitHeadline": "Initial plan"|null,
33
+ "sessionActivity": "active"|"concluded"|"idle"|null,
34
+ "sessionRunId": 123|null,
35
+ "sessionRunName": "..."|null,
36
+ "sessionRunStatus": "..."|null,
37
+ "sessionRunConclusion": string|null,
38
+ "sessionRunCreatedAt": "..."|null,
39
+ "sessionConfidence": "high"|null
40
+ }
41
+ Error output (stderr, JSON):
42
+ Argument/usage errors:
43
+ { "ok": false, "error": "...", "usage": "..." }
44
+ gh/runtime failures:
45
+ { "ok": false, "error": "..." }`.trim();
46
+ export const LINKED_PR_STATE = Object.freeze({
47
+ NO_LINKED_PR: "no_linked_pr",
48
+ PRIOR_LINKED_PR_CLOSED_UNMERGED: "prior_linked_pr_closed_unmerged",
49
+ COPILOT_SESSION_ACTIVE: "copilot_session_active",
50
+ WAITING_FOR_INITIAL_COPILOT_IMPLEMENTATION: "waiting_for_initial_copilot_implementation",
51
+ LINKED_PR_READY_FOR_FOLLOWUP: "linked_pr_ready_for_followup",
52
+ });
53
+ const INITIAL_COPILOT_PR_FACTS_QUERY = [
54
+ "query($owner:String!, $name:String!, $pr:Int!) {",
55
+ " repository(owner:$owner, name:$name) {",
56
+ " pullRequest(number:$pr) {",
57
+ " number",
58
+ " url",
59
+ " headRefName",
60
+ " state",
61
+ " isDraft",
62
+ " changedFiles",
63
+ " repository { nameWithOwner }",
64
+ " author {",
65
+ " __typename",
66
+ " login",
67
+ " }",
68
+ " commits(first: 2) {",
69
+ " totalCount",
70
+ " nodes {",
71
+ " commit {",
72
+ " messageHeadline",
73
+ " }",
74
+ " }",
75
+ " }",
76
+ " }",
77
+ " }",
78
+ "}",
79
+ ].join("\n");
80
+ const parseError = buildParseError(USAGE);
81
+ export function parseDetectInitialCopilotPrStateCliArgs(argv) {
82
+ const args = [...argv];
83
+ const options = {
84
+ help: false,
85
+ repo: undefined,
86
+ issue: undefined,
87
+ };
88
+ while (args.length > 0) {
89
+ const token = args.shift();
90
+ if (token === "--help" || token === "-h") {
91
+ options.help = true;
92
+ return options;
93
+ }
94
+ if (token === "--repo") {
95
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
96
+ continue;
97
+ }
98
+ if (token === "--issue") {
99
+ options.issue = parseIssueNumber(requireOptionValue(args, "--issue", parseError), parseError);
100
+ continue;
101
+ }
102
+ throw parseError(`Unknown argument: ${token}`);
103
+ }
104
+ if (options.repo === undefined || options.issue === undefined) {
105
+ throw parseError("detect-initial-copilot-pr-state requires both --repo <owner/name> and --issue <number>");
106
+ }
107
+ try {
108
+ parseRepoSlug(options.repo);
109
+ } catch (error) {
110
+ throw parseError(error instanceof Error ? error.message : String(error));
111
+ }
112
+ return options;
113
+ }
114
+ function buildQueryArgs({ owner, name, pr }) {
115
+ return [
116
+ "api",
117
+ "graphql",
118
+ "--field",
119
+ `owner=${owner}`,
120
+ "--field",
121
+ `name=${name}`,
122
+ "-F",
123
+ `pr=${pr}`,
124
+ "--field",
125
+ `query=${INITIAL_COPILOT_PR_FACTS_QUERY}`,
126
+ ];
127
+ }
128
+ function getRequiredString(value, fieldName) {
129
+ if (typeof value !== "string" || value.length === 0) {
130
+ throw new Error(`Missing required PR facts: ${fieldName}`);
131
+ }
132
+ return value;
133
+ }
134
+ function getRequiredBoolean(value, fieldName) {
135
+ if (typeof value !== "boolean") {
136
+ throw new Error(`Missing required PR facts: ${fieldName}`);
137
+ }
138
+ return value;
139
+ }
140
+ function getRequiredNonNegativeInteger(value, fieldName) {
141
+ if (!Number.isInteger(value) || value < 0) {
142
+ throw new Error(`Missing required PR facts: ${fieldName}`);
143
+ }
144
+ return value;
145
+ }
146
+ function getRequiredPositiveInteger(value, fieldName) {
147
+ if (!Number.isInteger(value) || value <= 0) {
148
+ throw new Error(`Missing required PR facts: ${fieldName}`);
149
+ }
150
+ return value;
151
+ }
152
+ function normalizeRepoForComparison(repo) {
153
+ return typeof repo === "string" ? repo.trim().toLowerCase() : "";
154
+ }
155
+ function isCopilotAuthored(authorLogin) {
156
+ const normalized = String(authorLogin).trim().toLowerCase();
157
+ return normalized === "copilot"
158
+ || normalized === "copilot-swe-agent"
159
+ || normalized === "app/copilot-swe-agent"
160
+ || normalized === "copilot-swe-agent[bot]";
161
+ }
162
+ function classifyInitialCopilotPrState({ repo, facts }) {
163
+ const isBootstrapOnly = facts.state === "OPEN"
164
+ && normalizeRepoForComparison(facts.repository) === normalizeRepoForComparison(repo)
165
+ && facts.isDraft
166
+ && isCopilotAuthored(facts.authorLogin)
167
+ && facts.commitCount === 1
168
+ && facts.changedFiles === 0
169
+ && facts.soleCommitHeadline === "Initial plan";
170
+ if (facts.sessionActivity === "active") {
171
+ return LINKED_PR_STATE.COPILOT_SESSION_ACTIVE;
172
+ }
173
+ return isBootstrapOnly
174
+ ? LINKED_PR_STATE.WAITING_FOR_INITIAL_COPILOT_IMPLEMENTATION
175
+ : LINKED_PR_STATE.LINKED_PR_READY_FOR_FOLLOWUP;
176
+ }
177
+ async function fetchLinkedPrFacts({ repo, prNumber }, { env, ghCommand }) {
178
+ const { owner, name } = parseRepoSlug(repo);
179
+ const result = await runChild(
180
+ ghCommand,
181
+ buildQueryArgs({ owner, name, pr: prNumber }),
182
+ env,
183
+ );
184
+ if (result.code !== 0) {
185
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
186
+ throw new Error(`gh command failed: ${detail}`);
187
+ }
188
+ const payload = parseJsonText(result.stdout);
189
+ const pr = payload?.data?.repository?.pullRequest;
190
+ if (!pr || typeof pr !== "object") {
191
+ throw new Error(`Missing required PR facts: data.repository.pullRequest for linked PR #${prNumber}`);
192
+ }
193
+ const commitCount = getRequiredNonNegativeInteger(pr?.commits?.totalCount, "pullRequest.commits.totalCount");
194
+ const commitNode = Array.isArray(pr?.commits?.nodes) ? pr.commits.nodes[0] : null;
195
+ const soleCommitHeadline = commitCount === 1
196
+ ? getRequiredString(commitNode?.commit?.messageHeadline, "pullRequest.commits.nodes[0].commit.messageHeadline")
197
+ : null;
198
+ return {
199
+ number: getRequiredPositiveInteger(pr.number, "pullRequest.number"),
200
+ url: getRequiredString(pr.url, "pullRequest.url"),
201
+ headBranch: getRequiredString(pr.headRefName, "pullRequest.headRefName"),
202
+ state: getRequiredString(pr.state, "pullRequest.state"),
203
+ isDraft: getRequiredBoolean(pr.isDraft, "pullRequest.isDraft"),
204
+ changedFiles: getRequiredNonNegativeInteger(pr.changedFiles, "pullRequest.changedFiles"),
205
+ repository: getRequiredString(pr?.repository?.nameWithOwner, "pullRequest.repository.nameWithOwner"),
206
+ authorLogin: getRequiredString(pr?.author?.login, "pullRequest.author.login"),
207
+ commitCount,
208
+ soleCommitHeadline,
209
+ };
210
+ }
211
+ export async function detectInitialCopilotPrState({ repo, issue }, { env = process.env, ghCommand = "gh" } = {}) {
212
+ const linked = await detectLinkedIssuePr({ repo, issue }, { env, ghCommand });
213
+ if (!linked.hasOpenLinkedPr || linked.prNumber === null) {
214
+ if (linked.hasPriorClosedUnmergedPr) {
215
+ return {
216
+ ok: true,
217
+ repo,
218
+ issue,
219
+ state: LINKED_PR_STATE.PRIOR_LINKED_PR_CLOSED_UNMERGED,
220
+ prNumber: linked.priorClosedUnmergedPrNumber ?? null,
221
+ prUrl: linked.priorClosedUnmergedPrUrl ?? null,
222
+ headBranch: null,
223
+ authorLogin: null,
224
+ isDraft: null,
225
+ changedFiles: null,
226
+ commitCount: null,
227
+ soleCommitHeadline: null,
228
+ sessionActivity: null,
229
+ sessionRunId: null,
230
+ sessionRunName: null,
231
+ sessionRunStatus: null,
232
+ sessionRunConclusion: null,
233
+ sessionRunCreatedAt: null,
234
+ sessionConfidence: null,
235
+ };
236
+ }
237
+ return {
238
+ ok: true,
239
+ repo,
240
+ issue,
241
+ state: LINKED_PR_STATE.NO_LINKED_PR,
242
+ prNumber: null,
243
+ prUrl: null,
244
+ headBranch: null,
245
+ authorLogin: null,
246
+ isDraft: null,
247
+ changedFiles: null,
248
+ commitCount: null,
249
+ soleCommitHeadline: null,
250
+ sessionActivity: null,
251
+ sessionRunId: null,
252
+ sessionRunName: null,
253
+ sessionRunStatus: null,
254
+ sessionRunConclusion: null,
255
+ sessionRunCreatedAt: null,
256
+ sessionConfidence: null,
257
+ };
258
+ }
259
+ const facts = await fetchLinkedPrFacts({ repo, prNumber: linked.prNumber }, { env, ghCommand });
260
+ let sessionActivity = null;
261
+ if (facts.isDraft && isCopilotAuthored(facts.authorLogin)) {
262
+ sessionActivity = await detectCopilotSessionActivity(
263
+ {
264
+ repo,
265
+ branch: facts.headBranch,
266
+ },
267
+ { env, ghCommand },
268
+ );
269
+ }
270
+ return {
271
+ ok: true,
272
+ repo,
273
+ issue,
274
+ state: classifyInitialCopilotPrState({
275
+ repo,
276
+ facts: {
277
+ ...facts,
278
+ sessionActivity: sessionActivity?.activity ?? null,
279
+ },
280
+ }),
281
+ prNumber: facts.number,
282
+ prUrl: facts.url,
283
+ headBranch: facts.headBranch,
284
+ authorLogin: facts.authorLogin,
285
+ isDraft: facts.isDraft,
286
+ changedFiles: facts.changedFiles,
287
+ commitCount: facts.commitCount,
288
+ soleCommitHeadline: facts.soleCommitHeadline,
289
+ sessionActivity: sessionActivity?.activity ?? null,
290
+ sessionRunId: sessionActivity?.runId ?? null,
291
+ sessionRunName: sessionActivity?.runName ?? null,
292
+ sessionRunStatus: sessionActivity?.runStatus ?? null,
293
+ sessionRunConclusion: sessionActivity?.runConclusion ?? null,
294
+ sessionRunCreatedAt: sessionActivity?.runCreatedAt ?? null,
295
+ sessionConfidence: sessionActivity?.confidence ?? null,
296
+ };
297
+ }
298
+ export async function runCli(
299
+ argv = process.argv.slice(2),
300
+ { stdout = process.stdout, env = process.env, ghCommand = "gh" } = {},
301
+ ) {
302
+ const options = parseDetectInitialCopilotPrStateCliArgs(argv);
303
+ if (options.help) {
304
+ stdout.write(`${USAGE}\n`);
305
+ return;
306
+ }
307
+ const result = await detectInitialCopilotPrState(
308
+ { repo: options.repo, issue: options.issue },
309
+ { env, ghCommand },
310
+ );
311
+ stdout.write(`${JSON.stringify(result)}\n`);
312
+ }
313
+ if (isDirectCliRun(import.meta.url)) {
314
+ runCli().catch((error) => {
315
+ process.stderr.write(`${formatCliError(error)}\n`);
316
+ process.exitCode = 1;
317
+ });
318
+ }
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ import { statSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
6
+ import { parsePrNumber, requireOptionValue, runChild } from "../_cli-primitives.mjs";
7
+ import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
8
+
9
+ const USAGE = `Usage: detect-internal-only-pr.mjs --repo <owner/name> --pr <number> [--config <path>]
10
+ Detect whether a PR only touches internal tooling files (scripts, docs, tests, config)
11
+ and should suppress external Copilot review.
12
+
13
+ Required:
14
+ --repo <owner/name> Repository slug (e.g. owner/repo)
15
+ --pr <number> Pull request number
16
+ Optional:
17
+ --config <path> Path to .devloops (default: auto-detect, tries .devloops then .pi/dev-loop/settings.*)
18
+ --label-check Also check for explicit "internal_only" label on the PR
19
+ Output (stdout, JSON):
20
+ { "ok": true, "internalOnly": true|false, "files": ["path1", "path2", ...],
21
+ "reason": "...", "repo": "...", "pr": N }
22
+ Exit codes:
23
+ 0 Success
24
+ 1 Argument error or gh failure`.trim();
25
+
26
+ const parseError = buildParseError(USAGE);
27
+
28
+ // Shipped default patterns used as fallback when no config is found.
29
+ const SHIPPED_DEFAULT_PATTERNS = [
30
+ "^scripts/",
31
+ "^docs/",
32
+ "^skills/docs/",
33
+ "^\\.pi/",
34
+ "^\\.github/",
35
+ "^test/",
36
+ ];
37
+
38
+ function findRepoRoot(cwd = process.cwd()) {
39
+ let dir = cwd;
40
+ while (true) {
41
+ try {
42
+ const s = statSync(path.join(dir, ".git"));
43
+ if (s.isDirectory() || s.isFile()) return dir;
44
+ } catch {
45
+ }
46
+ const parent = path.dirname(dir);
47
+ if (parent === dir) return null;
48
+ dir = parent;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Load internal path patterns from config, with precedence:
54
+ * 1. --config flag (explicit path)
55
+ * 2. Auto-detect from repo root (.devloops first, then .pi/dev-loop/settings.* or overrides.*)
56
+ * 3. Shipped defaults
57
+ *
58
+ * Returns a flat array of regex pattern strings.
59
+ * Falls back to shipped defaults when parsed patterns are empty or invalid.
60
+ */
61
+ function loadInternalPathPatterns(configPath) {
62
+ // --config flag takes priority
63
+ if (configPath) {
64
+ const patterns = tryLoadFromFile(configPath);
65
+ if (patterns && patterns.length > 0) return patterns;
66
+ return [...SHIPPED_DEFAULT_PATTERNS];
67
+ }
68
+
69
+ // Auto-detect from repo root
70
+ const repoRoot = findRepoRoot();
71
+ if (repoRoot) {
72
+ const candidates = [
73
+ // .devloops at repo root (primary)
74
+ path.join(repoRoot, ".devloops"),
75
+ path.join(repoRoot, ".devloops.yaml"),
76
+ path.join(repoRoot, ".devloops.yml"),
77
+ path.join(repoRoot, ".devloops.json"),
78
+ // Legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
79
+ path.join(repoRoot, ".pi", "dev-loop", "settings.yaml"),
80
+ path.join(repoRoot, ".pi", "dev-loop", "settings.yml"),
81
+ path.join(repoRoot, ".pi", "dev-loop", "settings.json"),
82
+ path.join(repoRoot, ".pi", "dev-loop", "overrides.yaml"),
83
+ path.join(repoRoot, ".pi", "dev-loop", "overrides.yml"),
84
+ path.join(repoRoot, ".pi", "dev-loop", "overrides.json"),
85
+ ];
86
+ for (const candidate of candidates) {
87
+ const patterns = tryLoadFromFile(candidate);
88
+ if (patterns && patterns.length > 0) return patterns;
89
+ }
90
+ }
91
+
92
+ // Fall back to shipped defaults
93
+ return [...SHIPPED_DEFAULT_PATTERNS];
94
+ }
95
+
96
+ function tryLoadFromFile(filePath) {
97
+ try {
98
+ const raw = readFileSync(filePath, "utf8");
99
+ const parsed = filePath.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
100
+ const patterns = parsed?.internalPathPatterns;
101
+ if (Array.isArray(patterns)) {
102
+ const trimmed = patterns.filter(p => typeof p === "string" && p.trim()).map(p => p.trim());
103
+ return trimmed.length > 0 ? trimmed : null;
104
+ }
105
+ } catch {
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function buildPatternMatchers(patterns) {
111
+ return patterns.map(p => {
112
+ try {
113
+ return new RegExp(p);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }).filter(r => r !== null);
118
+ }
119
+
120
+ export function parseCliArgs(argv) {
121
+ const args = [...argv];
122
+ const options = {
123
+ help: false,
124
+ repo: undefined,
125
+ pr: undefined,
126
+ config: undefined,
127
+ labelCheck: false,
128
+ };
129
+ while (args.length > 0) {
130
+ const token = args.shift();
131
+ if (token === "--help" || token === "-h") {
132
+ options.help = true;
133
+ return options;
134
+ }
135
+ if (token === "--repo") {
136
+ options.repo = requireOptionValue(args, "--repo", parseError).trim();
137
+ continue;
138
+ }
139
+ if (token === "--pr") {
140
+ options.pr = parsePrNumber(requireOptionValue(args, "--pr", parseError), parseError);
141
+ continue;
142
+ }
143
+ if (token === "--config") {
144
+ options.config = requireOptionValue(args, "--config", parseError).trim();
145
+ continue;
146
+ }
147
+ if (token === "--label-check") {
148
+ options.labelCheck = true;
149
+ continue;
150
+ }
151
+ throw parseError(`Unknown argument: ${token}`);
152
+ }
153
+ if (options.repo === undefined || options.pr === undefined) {
154
+ throw parseError("detect-internal-only-pr requires both --repo <owner/name> and --pr <number>");
155
+ }
156
+ try {
157
+ parseRepoSlug(options.repo);
158
+ } catch (error) {
159
+ throw parseError(error instanceof Error ? error.message : String(error));
160
+ }
161
+ return options;
162
+ }
163
+
164
+ async function fetchPrFiles({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
165
+ const result = await runChild(
166
+ ghCommand,
167
+ ["pr", "view", String(pr), "--repo", repo, "--json", "files", "--jq", ".files[].path"],
168
+ env,
169
+ );
170
+ if (result.code !== 0) {
171
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
172
+ throw new Error(`gh command failed: ${detail}`);
173
+ }
174
+ const paths = result.stdout.trim().split("\n").filter(Boolean);
175
+ return paths;
176
+ }
177
+
178
+ async function fetchPrLabels({ repo, pr }, { env = process.env, ghCommand = "gh" } = {}) {
179
+ const result = await runChild(
180
+ ghCommand,
181
+ ["pr", "view", String(pr), "--repo", repo, "--json", "labels", "--jq", ".labels[].name"],
182
+ env,
183
+ );
184
+ if (result.code !== 0) {
185
+ return []; // Best-effort: label check failure is not fatal
186
+ }
187
+ const labels = result.stdout.trim().split("\n").filter(Boolean);
188
+ return labels;
189
+ }
190
+
191
+ /**
192
+ * Detect whether a PR is internal-only using a configurable whitelist.
193
+ *
194
+ * Single-whitelist logic:
195
+ * - If ALL changed files match at least one internal pattern → internalOnly=true
196
+ * - If ANY changed file doesn't match any pattern → internalOnly=false
197
+ * - No blacklist needed — a non-matching file is consumer-facing by definition.
198
+ */
199
+ export async function detectInternalOnly(options, { env = process.env, ghCommand = "gh" } = {}) {
200
+ const patterns = loadInternalPathPatterns(options.config);
201
+ const matchers = buildPatternMatchers(patterns);
202
+ const files = await fetchPrFiles(options, { env, ghCommand });
203
+
204
+ if (files.length === 0) {
205
+ return {
206
+ ok: true,
207
+ internalOnly: false,
208
+ files: [],
209
+ reason: "No files changed; cannot determine internal-only status",
210
+ repo: options.repo,
211
+ pr: options.pr,
212
+ };
213
+ }
214
+
215
+ // Single whitelist: any non-matching file → NOT internal-only
216
+ const nonMatching = files.filter(f => !matchers.some(r => r.test(f)));
217
+ if (nonMatching.length > 0) {
218
+ return {
219
+ ok: true,
220
+ internalOnly: false,
221
+ files,
222
+ reason: `Consumer-facing file(s) changed: ${nonMatching.join(", ")}`,
223
+ repo: options.repo,
224
+ pr: options.pr,
225
+ };
226
+ }
227
+
228
+ // Check for explicit internal_only label if requested (confirmation only)
229
+ if (options.labelCheck) {
230
+ const labels = await fetchPrLabels(options, { env, ghCommand });
231
+ if (labels.includes("internal_only")) {
232
+ // Label confirms — path check already passed
233
+ }
234
+ }
235
+
236
+ return {
237
+ ok: true,
238
+ internalOnly: true,
239
+ files,
240
+ reason: `All ${files.length} changed file(s) are internal tooling only (scripts/docs/tests/config)`,
241
+ repo: options.repo,
242
+ pr: options.pr,
243
+ };
244
+ }
245
+
246
+ export async function runCli(
247
+ argv = process.argv.slice(2),
248
+ {
249
+ stdout = process.stdout,
250
+ env = process.env,
251
+ ghCommand = "gh",
252
+ } = {},
253
+ ) {
254
+ const options = parseCliArgs(argv);
255
+ if (options.help) {
256
+ stdout.write(`${USAGE}\n`);
257
+ return;
258
+ }
259
+ const result = await detectInternalOnly(options, { env, ghCommand });
260
+ stdout.write(`${JSON.stringify(result)}\n`);
261
+ }
262
+
263
+ if (isDirectCliRun(import.meta.url)) {
264
+ runCli().catch((error) => {
265
+ process.stderr.write(`${formatCliError(error)}\n`);
266
+ process.exitCode = 1;
267
+ });
268
+ }
269
+
270
+ export { findRepoRoot, loadInternalPathPatterns, buildPatternMatchers, tryLoadFromFile, SHIPPED_DEFAULT_PATTERNS };