@dev-loops/core 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 (54) hide show
  1. package/bin/capture-deep-persona-signals.mjs +143 -0
  2. package/bin/ensure-phase-files.mjs +7 -0
  3. package/bin/log-bash-exit-1.mjs +7 -0
  4. package/bin/parse-review-threads.mjs +7 -0
  5. package/package.json +78 -0
  6. package/src/analysis/change-classifier.mjs +146 -0
  7. package/src/analysis/diff-analyzer.mjs +285 -0
  8. package/src/bash-exit-one.mjs +130 -0
  9. package/src/cli/helpers.mjs +22 -0
  10. package/src/cli/primitives.mjs +70 -0
  11. package/src/cli/retry-wrapper.mjs +169 -0
  12. package/src/cli/subcommand-runner.mjs +246 -0
  13. package/src/config/config.mjs +965 -0
  14. package/src/debt/cluster.mjs +240 -0
  15. package/src/debt/debt-finding.mjs +68 -0
  16. package/src/debt/debt-signal.mjs +46 -0
  17. package/src/debt/deep-persona-signals.mjs +266 -0
  18. package/src/debt/remediation-to-issue.mjs +121 -0
  19. package/src/debt/score.mjs +127 -0
  20. package/src/debt/shape.mjs +214 -0
  21. package/src/github/copilot-helpers.mjs +343 -0
  22. package/src/github/repo-slug.mjs +105 -0
  23. package/src/github/review-threads.mjs +343 -0
  24. package/src/harness/adapter.mjs +57 -0
  25. package/src/harness/index.mjs +3 -0
  26. package/src/harness/noop-adapter.mjs +22 -0
  27. package/src/harness/pi-adapter.mjs +47 -0
  28. package/src/loop/async-start-contract.mjs +170 -0
  29. package/src/loop/conductor-routing.mjs +817 -0
  30. package/src/loop/copilot-ci-status.mjs +255 -0
  31. package/src/loop/copilot-loop-iterations.mjs +161 -0
  32. package/src/loop/copilot-loop-state.mjs +510 -0
  33. package/src/loop/handoff-envelope.mjs +800 -0
  34. package/src/loop/issue-refinement-artifact.mjs +268 -0
  35. package/src/loop/lifecycle-state.mjs +342 -0
  36. package/src/loop/phase-files.mjs +187 -0
  37. package/src/loop/policy-constants.mjs +17 -0
  38. package/src/loop/pr-gate-coordination.mjs +1278 -0
  39. package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
  40. package/src/loop/public-dev-loop-routing.mjs +1746 -0
  41. package/src/loop/queue-board-ordering.mjs +38 -0
  42. package/src/loop/queue-board-sync.mjs +223 -0
  43. package/src/loop/queue-driver.mjs +164 -0
  44. package/src/loop/queue-parallel.mjs +190 -0
  45. package/src/loop/queue-state.mjs +230 -0
  46. package/src/loop/retrospective-checkpoint.mjs +178 -0
  47. package/src/loop/reviewer-loop-state.mjs +456 -0
  48. package/src/loop/run-inspection.mjs +604 -0
  49. package/src/loop/steering.mjs +793 -0
  50. package/src/loop/timeout-policy.mjs +73 -0
  51. package/src/loop/tracker-first-loop-state.mjs +87 -0
  52. package/src/loop/tracker-pr-state.mjs +301 -0
  53. package/src/loop/worktree-guard.mjs +141 -0
  54. package/src/refinement/ac-dod-matrix.mjs +95 -0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseReviewThreads, readInput, parseJsonText, formatCliError } from "../src/github/review-threads.mjs";
6
+ import { extractDeepPersonaSignals } from "../src/debt/deep-persona-signals.mjs";
7
+
8
+ export const USAGE = [
9
+ "Usage: capture-deep-persona-signals.mjs --input <path> --pr-number <n> --pr-url <url> [--output-dir <path>]",
10
+ "",
11
+ "Arguments:",
12
+ " --input <path> Path to normalized review-thread JSON (required)",
13
+ " --pr-number <n> PR number for metadata (required)",
14
+ " --pr-url <url> PR URL for metadata (required)",
15
+ " --output-dir <path> Output directory for emitted artifact (default: .pi/debt/signals/)",
16
+ ].join("\n");
17
+
18
+ /**
19
+ * Parse CLI arguments for the capture-deep-persona-signals CLI.
20
+ *
21
+ * @param {string[]} argv - Argument list (e.g. process.argv.slice(2))
22
+ * @returns {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }}
23
+ */
24
+ export function parseArgs(argv) {
25
+ const args = [...argv];
26
+ const options = {
27
+ inputPath: undefined,
28
+ prNumber: undefined,
29
+ prUrl: undefined,
30
+ outputDir: ".pi/debt/signals",
31
+ };
32
+
33
+ while (args.length > 0) {
34
+ const token = args.shift();
35
+
36
+ switch (token) {
37
+ case "--input": {
38
+ const value = args.shift();
39
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
40
+ throw Object.assign(new Error("Missing value for --input"), { usage: USAGE });
41
+ }
42
+ options.inputPath = value;
43
+ break;
44
+ }
45
+ case "--pr-number": {
46
+ const value = args.shift();
47
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
48
+ throw Object.assign(new Error("Missing value for --pr-number"), { usage: USAGE });
49
+ }
50
+ if (!/^\d+$/.test(value)) {
51
+ throw Object.assign(new Error(`--pr-number must be a positive integer, got: ${value}`), { usage: USAGE });
52
+ }
53
+ options.prNumber = value;
54
+ break;
55
+ }
56
+ case "--pr-url": {
57
+ const value = args.shift();
58
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
59
+ throw Object.assign(new Error("Missing value for --pr-url"), { usage: USAGE });
60
+ }
61
+ options.prUrl = value;
62
+ break;
63
+ }
64
+ case "--output-dir": {
65
+ const value = args.shift();
66
+ if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) {
67
+ throw Object.assign(new Error("Missing value for --output-dir"), { usage: USAGE });
68
+ }
69
+ options.outputDir = value;
70
+ break;
71
+ }
72
+ default:
73
+ throw Object.assign(new Error(`Unknown argument: ${token}`), { usage: USAGE });
74
+ }
75
+ }
76
+
77
+ if (!options.inputPath) {
78
+ throw Object.assign(new Error("--input is required"), { usage: USAGE });
79
+ }
80
+ if (!options.prNumber) {
81
+ throw Object.assign(new Error("--pr-number is required"), { usage: USAGE });
82
+ }
83
+ if (!options.prUrl) {
84
+ throw Object.assign(new Error("--pr-url is required"), { usage: USAGE });
85
+ }
86
+
87
+ return /** @type {{ inputPath: string, prNumber: string, prUrl: string, outputDir: string }} */ (options);
88
+ }
89
+
90
+ /**
91
+ * Generate the output filename.
92
+ * @param {string} prNumber
93
+ * @returns {string}
94
+ */
95
+ export function outputFilename(prNumber) {
96
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
97
+ return `deep-persona-signals-${prNumber}-${ts}.json`;
98
+ }
99
+
100
+ export async function run(argv = process.argv.slice(2)) {
101
+ const options = parseArgs(argv);
102
+
103
+ // Read and parse the review-thread input
104
+ const rawText = await readInput({ inputPath: options.inputPath });
105
+ const parsed = parseReviewThreads(parseJsonText(rawText));
106
+
107
+ // Extract deep-persona signals
108
+ const signals = extractDeepPersonaSignals(parsed, {
109
+ prNumber: options.prNumber,
110
+ prUrl: options.prUrl,
111
+ });
112
+
113
+ // Build artifact envelope
114
+ const artifact = {
115
+ version: 1,
116
+ generatedAt: new Date().toISOString(),
117
+ prNumber: Number(options.prNumber),
118
+ prUrl: options.prUrl,
119
+ source: "pr_review_deep_persona",
120
+ signalCount: signals.length,
121
+ signals,
122
+ };
123
+
124
+ // Write to output directory
125
+ const outDir = resolve(options.outputDir);
126
+ await mkdir(outDir, { recursive: true });
127
+ const outPath = join(outDir, outputFilename(options.prNumber));
128
+ await writeFile(outPath, JSON.stringify(artifact, null, 2) + "\n", "utf8");
129
+
130
+ process.stdout.write(JSON.stringify({ ok: true, outputPath: outPath, signalCount: signals.length }) + "\n");
131
+ }
132
+
133
+ // Only auto-run when executed directly (not imported)
134
+ const scriptPath = fileURLToPath(import.meta.url);
135
+ if (process.argv[1] === scriptPath) {
136
+ run().catch((error) => {
137
+ if (error.usage) {
138
+ process.stderr.write(error.usage + "\n\n");
139
+ }
140
+ process.stderr.write(formatCliError(error) + "\n");
141
+ process.exitCode = 1;
142
+ });
143
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/loop/phase-files.mjs";
3
+
4
+ runCli().catch((error) => {
5
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/bash-exit-one.mjs";
3
+
4
+ runCli().catch((error) => {
5
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { formatCliError, runCli } from "../src/github/review-threads.mjs";
3
+
4
+ runCli().catch((error) => {
5
+ process.stderr.write(`${formatCliError(error)}\n`);
6
+ process.exitCode = 1;
7
+ });
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@dev-loops/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
6
+ "exports": {
7
+ "./bash-exit-one": "./src/bash-exit-one.mjs",
8
+ "./cli/helpers": "./src/cli/helpers.mjs",
9
+ "./cli/primitives": "./src/cli/primitives.mjs",
10
+ "./cli/retry-wrapper": "./src/cli/retry-wrapper.mjs",
11
+ "./cli/subcommand-runner": "./src/cli/subcommand-runner.mjs",
12
+ "./config": "./src/config/config.mjs",
13
+ "./debt/cluster": "./src/debt/cluster.mjs",
14
+ "./debt/finding": "./src/debt/debt-finding.mjs",
15
+ "./debt/remediation-to-issue": "./src/debt/remediation-to-issue.mjs",
16
+ "./debt/score": "./src/debt/score.mjs",
17
+ "./debt/shape": "./src/debt/shape.mjs",
18
+ "./debt/signal": "./src/debt/debt-signal.mjs",
19
+ "./debt/signals": "./src/debt/deep-persona-signals.mjs",
20
+ "./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
21
+ "./github/repo-slug": "./src/github/repo-slug.mjs",
22
+ "./github/review-threads": "./src/github/review-threads.mjs",
23
+ "./loop/async-start-contract": "./src/loop/async-start-contract.mjs",
24
+ "./loop/conductor-routing": "./src/loop/conductor-routing.mjs",
25
+ "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
26
+ "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
27
+ "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
28
+ "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
29
+ "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
30
+ "./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
31
+ "./loop/phase-files": "./src/loop/phase-files.mjs",
32
+ "./loop/policy-constants": "./src/loop/policy-constants.mjs",
33
+ "./loop/pr-gate-coordination": "./src/loop/pr-gate-coordination.mjs",
34
+ "./loop/public-dev-loop-routing": "./src/loop/public-dev-loop-routing.mjs",
35
+ "./loop/queue-driver": "./src/loop/queue-driver.mjs",
36
+ "./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
37
+ "./loop/queue-state": "./src/loop/queue-state.mjs",
38
+ "./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
39
+ "./loop/run-inspection": "./src/loop/run-inspection.mjs",
40
+ "./loop/steering": "./src/loop/steering.mjs",
41
+ "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
42
+ "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
43
+ "./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
44
+ "./harness": "./src/harness/index.mjs",
45
+ "./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
46
+ "./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
47
+ },
48
+ "bin": {
49
+ "dev-loops-log-bash-exit-1": "./bin/log-bash-exit-1.mjs",
50
+ "dev-loops-ensure-phase-files": "./bin/ensure-phase-files.mjs",
51
+ "dev-loops-parse-review-threads": "./bin/parse-review-threads.mjs",
52
+ "dev-loops-capture-deep-persona-signals": "./bin/capture-deep-persona-signals.mjs"
53
+ },
54
+ "files": [
55
+ "src/**/*.mjs",
56
+ "bin/**/*.mjs"
57
+ ],
58
+ "scripts": {
59
+ "test": "node --test ./test/*.test.mjs"
60
+ },
61
+ "publishConfig": {
62
+ "access": "public",
63
+ "provenance": true
64
+ },
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "https://github.com/mfittko/dev-loops.git"
68
+ },
69
+ "bugs": {
70
+ "url": "https://github.com/mfittko/dev-loops/issues"
71
+ },
72
+ "homepage": "https://github.com/mfittko/dev-loops#readme",
73
+ "dependencies": {
74
+ "yaml": "^2.9.0",
75
+ "zod": "^4.4.3"
76
+ },
77
+ "license": "MIT"
78
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Change category classification and angle relevance index.
3
+ *
4
+ * Maps change categories detected by the diff analyzer to relevant gate review
5
+ * angles.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Change categories
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** @enum {string} */
13
+ export const ChangeCategory = Object.freeze({
14
+ RENAME_ONLY: "RENAME_ONLY",
15
+ DOCS_ONLY: "DOCS_ONLY",
16
+ CONFIG_ONLY: "CONFIG_ONLY",
17
+ TEST_ONLY: "TEST_ONLY",
18
+ CI_ONLY: "CI_ONLY",
19
+ COMMENT_ONLY: "COMMENT_ONLY",
20
+ LOGIC_CHANGE: "LOGIC_CHANGE",
21
+ });
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Angle relevance index
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Map of ChangeCategory → relevant gate angles.
29
+ *
30
+ * Categories not listed default to an empty array (no angles relevant).
31
+ * When multiple categories match, the union of angles is taken.
32
+ *
33
+ * @type {Record<string, string[]>}
34
+ */
35
+ const CATEGORY_ANGLE_MAP = {
36
+ [ChangeCategory.RENAME_ONLY]: [
37
+ "scope", "correctness", "contract-surface", "docs", "link-check",
38
+ ],
39
+ [ChangeCategory.DOCS_ONLY]: [
40
+ "docs", "link-check", "contract-surface", "dry",
41
+ ],
42
+ [ChangeCategory.CONFIG_ONLY]: [
43
+ "config-drift", "scope", "correctness", "contract-surface",
44
+ ],
45
+ [ChangeCategory.TEST_ONLY]: [
46
+ "coverage", "correctness", "determinism",
47
+ ],
48
+ [ChangeCategory.CI_ONLY]: [
49
+ "ci-guard", "scope", "config-drift",
50
+ ],
51
+ [ChangeCategory.COMMENT_ONLY]: [
52
+ "dry",
53
+ ],
54
+ [ChangeCategory.LOGIC_CHANGE]: [
55
+ "correctness", "coverage", "kiss", "dry", "srp", "soc", "deep",
56
+ "ocp", "lsp", "isp", "dip", "yagni", "scope", "no-op", "determinism",
57
+ ],
58
+ };
59
+
60
+ /**
61
+ * Angles that are never skipped, regardless of diff analysis.
62
+ *
63
+ * @type {Set<string>}
64
+ */
65
+ const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-description"]);
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Resolution
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * @typedef {object} DynamicAngleResult
73
+ * @property {string[]} recommendedAngles — angles to run
74
+ * @property {string[]} skippedAngles — angles skipped with reasons
75
+ * @property {Record<string, string>} reasons — why each angle was skipped
76
+ * @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
77
+ */
78
+
79
+ /**
80
+ * Resolve which gate angles to run based on detected change categories.
81
+ *
82
+ * When the diff is ambiguous (contains LOGIC_CHANGE or multiple mixed categories),
83
+ * all configured angles are recommended (fallback-to-all).
84
+ *
85
+ * @param {object} options
86
+ * @param {string[]} options.configuredAngles — all angles configured for this gate
87
+ * @param {string[]} options.changeCategories — from diff analysis
88
+ * @param {boolean} [options.ambiguous] — from diff analysis
89
+ * @returns {DynamicAngleResult}
90
+ */
91
+ export function resolveDynamicAngles({
92
+ configuredAngles,
93
+ changeCategories,
94
+ ambiguous = false,
95
+ }) {
96
+ // Fallback: ambiguous diff → all angles
97
+ if (ambiguous) {
98
+ return {
99
+ recommendedAngles: [...configuredAngles],
100
+ skippedAngles: [],
101
+ reasons: {},
102
+ fallbackToAll: true,
103
+ };
104
+ }
105
+
106
+ // No change categories → all angles (defensive)
107
+ if (changeCategories.length === 0) {
108
+ return {
109
+ recommendedAngles: [...configuredAngles],
110
+ skippedAngles: [],
111
+ reasons: {},
112
+ fallbackToAll: true,
113
+ };
114
+ }
115
+
116
+ // Build recommended set from category union
117
+ const recommended = new Set();
118
+ for (const cat of changeCategories) {
119
+ const angles = CATEGORY_ANGLE_MAP[cat] ?? [];
120
+ for (const angle of angles) {
121
+ recommended.add(angle);
122
+ }
123
+ }
124
+
125
+ // Always-include angles
126
+ for (const angle of ALWAYS_INCLUDE) {
127
+ recommended.add(angle);
128
+ }
129
+
130
+ // Filter to only angles that are configured
131
+ const recommendedAngles = configuredAngles.filter((a) => recommended.has(a));
132
+ const skippedAngles = configuredAngles.filter((a) => !recommended.has(a));
133
+
134
+ // Build reasons
135
+ const reasons = {};
136
+ for (const angle of skippedAngles) {
137
+ reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
138
+ }
139
+
140
+ return {
141
+ recommendedAngles,
142
+ skippedAngles,
143
+ reasons,
144
+ fallbackToAll: false,
145
+ };
146
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Diff analysis for dynamic gate angle resolution.
3
+ *
4
+ * T0 — file-level: classifies files by extension and directory.
5
+ * T1 — hunk-level: classifies hunks by change type (comments, imports, config, etc.).
6
+ *
7
+ * T2 (AST-level) is deferred to a follow-up.
8
+ *
9
+ * This module is intentionally pure and side-effect free.
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // T0: File-level analysis
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * @typedef {object} T0Result
18
+ * @property {string[]} files — flat file paths
19
+ * @property {string[]} extensions — unique file extensions (lowercase, with dot)
20
+ * @property {string[]} directories — unique top-level path segments
21
+ * @property {boolean} renameOnly — true when all entries are renames (no adds/deletes/modifies)
22
+ * @property {boolean} allDocs — true when all files are under docs/ or are .md
23
+ */
24
+
25
+ /**
26
+ * Parse `git diff --name-status` output into a T0 analysis.
27
+ *
28
+ * Each line format: `<status>\t<path>` or `<status>\t<old>\t<new>`.
29
+ *
30
+ * @param {string} nameStatusOutput — raw stdout from `git diff --name-status`
31
+ * @returns {T0Result}
32
+ */
33
+
34
+ /**
35
+ * Normalize file path separators to forward slashes.
36
+ * Handles Windows backslash paths from git output on Windows.
37
+ *
38
+ * @param {string} filePath
39
+ * @returns {string}
40
+ */
41
+ function normalizeSep(filePath) {
42
+ return filePath.replaceAll("\\", "/");
43
+ }
44
+
45
+ export function analyzeT0(nameStatusOutput) {
46
+ const lines = nameStatusOutput.trim().split("\n").filter(Boolean);
47
+ const files = [];
48
+ const extensions = new Set();
49
+ const directories = new Set();
50
+ let renameCount = 0;
51
+
52
+ for (const line of lines) {
53
+ const parts = line.split("\t");
54
+ const status = parts[0];
55
+ const rawPath = parts.length >= 3 ? parts[2] : parts[1];
56
+ const path = normalizeSep(rawPath);
57
+ if (!path) continue;
58
+
59
+ files.push(path);
60
+
61
+ const ext = path.includes(".") ? "." + path.split(".").pop().toLowerCase() : "";
62
+ if (ext) extensions.add(ext);
63
+
64
+ const dir = path.split("/")[0];
65
+ if (dir) directories.add(dir);
66
+
67
+ if (status.startsWith("R")) renameCount++;
68
+ }
69
+
70
+ const renameOnly = lines.length > 0 && renameCount === lines.length;
71
+ const allDocs = lines.length > 0 && files.every(
72
+ (f) => normalizeSep(f).startsWith("docs/") || f.endsWith(".md") || f === "README.md",
73
+ );
74
+
75
+ return {
76
+ files,
77
+ extensions: [...extensions].sort(),
78
+ directories: [...directories].sort(),
79
+ renameOnly,
80
+ allDocs,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @typedef {"code" | "docs" | "config" | "test" | "ci" | "unknown"} FileCategory
86
+ */
87
+
88
+ /**
89
+ * Classify a single file path into a high-level category.
90
+ *
91
+ * @param {string} filePath
92
+ * @returns {FileCategory}
93
+ */
94
+ export function classifyFile(filePath) {
95
+ const fp = normalizeSep(filePath);
96
+ if (fp.startsWith(".github/")) {
97
+ return "ci";
98
+ }
99
+ if (fp.startsWith("docs/") || fp.endsWith(".md") || fp === "README.md") {
100
+ return "docs";
101
+ }
102
+ if (
103
+ fp.endsWith(".yml") || fp.endsWith(".yaml") ||
104
+ fp.endsWith(".json") || fp === "package.json"
105
+ ) {
106
+ return "config";
107
+ }
108
+ if (fp.includes(".test.") || fp.startsWith("test/")) {
109
+ return "test";
110
+ }
111
+ if (
112
+ fp.endsWith(".mjs") || fp.endsWith(".js") ||
113
+ fp.endsWith(".ts") || fp.endsWith(".mts")
114
+ ) {
115
+ return "code";
116
+ }
117
+ return "unknown";
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // T1: Hunk-level analysis
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * @typedef {object} T1Result
125
+ * @property {string[]} changeCategories — detected change categories
126
+ * @property {number} hunkCount
127
+ * @property {{ added: number, deleted: number }} lineStats
128
+ */
129
+
130
+ /**
131
+ * Check whether a diff line content (after stripping the + / - prefix) is
132
+ * a comment or blank line — i.e. not logic.
133
+ *
134
+ * @param {string} content — trimmed line content (without + / - prefix)
135
+ * @returns {boolean}
136
+ */
137
+ function isNonLogicLine(content) {
138
+ if (content === "") return true;
139
+ if (content.startsWith("//") || content.startsWith("/*") || content.startsWith("*")) return true;
140
+ // import/export/require lines are NOT non-logic — they change dependencies
141
+ // and should not be classified as COMMENT_ONLY
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Analyze unified diff hunks to classify change types.
147
+ *
148
+ * Detects:
149
+ * - COMMENT_ONLY: only comment lines changed
150
+ * - DOCS_ONLY: only .md files changed (from extensions)
151
+ * - CONFIG_ONLY: only config files changed
152
+ * - TEST_ONLY: only test files changed
153
+ * - RENAME_ONLY: all renames, no content changes
154
+ * - LOGIC_CHANGE: any non-trivial code change
155
+ *
156
+ * @param {string} diffOutput — raw unified diff output
157
+ * @param {T0Result} t0 — T0 result for context
158
+ * @returns {T1Result}
159
+ */
160
+ export function analyzeT1(diffOutput, t0) {
161
+ const lines = diffOutput.split("\n");
162
+ let hunkCount = 0;
163
+ let added = 0;
164
+ let deleted = 0;
165
+ const categories = new Set();
166
+
167
+ let inHunk = false;
168
+ let hasLogicChange = false;
169
+ let hasAnyChangedLine = false;
170
+ let allChangedLinesAreNonLogic = true;
171
+
172
+ for (const line of lines) {
173
+ // Track hunk headers
174
+ if (line.startsWith("@@")) {
175
+ hunkCount++;
176
+ inHunk = true;
177
+ continue;
178
+ }
179
+
180
+ if (!inHunk) continue;
181
+
182
+ // Track line stats and classify
183
+ if (line.startsWith("+") && !line.startsWith("+++")) {
184
+ added++;
185
+ hasAnyChangedLine = true;
186
+ const content = line.slice(1).trim();
187
+ if (!isNonLogicLine(content)) {
188
+ hasLogicChange = true;
189
+ allChangedLinesAreNonLogic = false;
190
+ }
191
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
192
+ deleted++;
193
+ hasAnyChangedLine = true;
194
+ const content = line.slice(1).trim();
195
+ if (!isNonLogicLine(content)) {
196
+ hasLogicChange = true;
197
+ allChangedLinesAreNonLogic = false;
198
+ }
199
+ }
200
+ }
201
+
202
+ // Build categories from T0 + hunk analysis
203
+ if (t0.renameOnly) categories.add("RENAME_ONLY");
204
+ if (t0.allDocs) categories.add("DOCS_ONLY");
205
+ if (t0.files.every((f) => classifyFile(f) === "config")) categories.add("CONFIG_ONLY");
206
+ if (t0.files.every((f) => classifyFile(f) === "test")) categories.add("TEST_ONLY");
207
+ if (t0.files.every((f) => classifyFile(f) === "ci")) categories.add("CI_ONLY");
208
+ if (hasLogicChange) categories.add("LOGIC_CHANGE");
209
+
210
+ // COMMENT_ONLY: hunkCount > 0 (real diff), has changed lines, all are non-logic,
211
+ // and not a rename-only change
212
+ if (hunkCount > 0 && hasAnyChangedLine && allChangedLinesAreNonLogic && !t0.renameOnly) {
213
+ categories.add("COMMENT_ONLY");
214
+ }
215
+
216
+ return {
217
+ changeCategories: [...categories],
218
+ hunkCount,
219
+ lineStats: { added, deleted },
220
+ };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Combined analysis
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * @typedef {object} DiffAnalysis
229
+ * @property {T0Result} t0
230
+ * @property {T1Result | null} t1
231
+ * @property {boolean} ambiguous — true when heuristics cannot confidently classify
232
+ */
233
+
234
+ /**
235
+ * Infer change categories from T0 analysis when T1 is not run.
236
+ *
237
+ * @param {T0Result} t0
238
+ * @returns {string[]}
239
+ */
240
+ function inferCategoriesFromT0(t0) {
241
+ const categories = [];
242
+ if (t0.renameOnly) categories.push("RENAME_ONLY");
243
+ if (t0.allDocs) categories.push("DOCS_ONLY");
244
+ if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
245
+ if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
246
+ if (t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
247
+ return categories;
248
+ }
249
+
250
+ /**
251
+ * Run full diff analysis (T0 + T1 if needed).
252
+ *
253
+ * T0 always runs. T1 runs when T0 doesn't produce a clear single-category result.
254
+ * When T1 is not run (unambiguous diff), categories are inferred from T0 so
255
+ * dynamic angle resolution can still narrow the angle list.
256
+ *
257
+ * @param {{ nameStatusOutput: string, diffOutput?: string }} input
258
+ * @returns {DiffAnalysis}
259
+ */
260
+ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
261
+ const t0 = analyzeT0(nameStatusOutput);
262
+ let t1 = null;
263
+
264
+ // T0 is unambiguous when: renameOnly, allDocs, or single clear category
265
+ const t0Ambiguous = !t0.renameOnly && !t0.allDocs && t0.files.length > 1 &&
266
+ new Set(t0.files.map(classifyFile)).size > 1;
267
+
268
+ if (t0Ambiguous && diffOutput) {
269
+ t1 = analyzeT1(diffOutput, t0);
270
+ }
271
+
272
+ // When t1 is null (unambiguous diff), infer categories from t0
273
+ // so dynamic angle resolution can narrow for config-only / test-only etc.
274
+ if (!t1) {
275
+ t1 = {
276
+ changeCategories: inferCategoriesFromT0(t0),
277
+ hunkCount: 0,
278
+ lineStats: { added: 0, deleted: 0 },
279
+ };
280
+ }
281
+
282
+ const ambiguous = t0Ambiguous && (t1.changeCategories.length === 0 || t1.changeCategories.includes("LOGIC_CHANGE"));
283
+
284
+ return { t0, t1, ambiguous };
285
+ }