@guilz-dev/sdlc-gh 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 (176) hide show
  1. package/.github/CODEOWNERS +5 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +68 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +39 -0
  5. package/.github/ISSUE_TEMPLATE/support.yml +56 -0
  6. package/.github/ISSUE_TEMPLATE/task.yml +89 -0
  7. package/.github/agents/implementer.agent.md +17 -0
  8. package/.github/agents/reviewer.agent.md +18 -0
  9. package/.github/agents/triager.agent.md +13 -0
  10. package/.github/aw/actions-lock.json +9 -0
  11. package/.github/copilot-instructions.md +35 -0
  12. package/.github/hooks/hooks.json +12 -0
  13. package/.github/instructions/core.instructions.md +11 -0
  14. package/.github/instructions/profiles/go.instructions.md +10 -0
  15. package/.github/instructions/profiles/php.instructions.md +11 -0
  16. package/.github/instructions/profiles/python.instructions.md +11 -0
  17. package/.github/instructions/profiles/ruby.instructions.md +11 -0
  18. package/.github/instructions/profiles/typescript.instructions.md +11 -0
  19. package/.github/labels.yml +55 -0
  20. package/.github/pull_request_template.md +33 -0
  21. package/.github/ruleset.example.json +33 -0
  22. package/.github/ruleset.harness-eval.example.json +29 -0
  23. package/.github/skills/quality-loop/SKILL.md +23 -0
  24. package/.github/workflows/agent-retry-orchestrator.yml +161 -0
  25. package/.github/workflows/copilot-setup-steps.yml +64 -0
  26. package/.github/workflows/eval-ci.yml +169 -0
  27. package/.github/workflows/eval-drift.yml +75 -0
  28. package/.github/workflows/gh-aw-dogfood-ci.yml +73 -0
  29. package/.github/workflows/harness-ci.yml +244 -0
  30. package/.github/workflows/harness-sync.yml +28 -0
  31. package/.github/workflows/l1-readiness-check.yml +45 -0
  32. package/.github/workflows/labels-sync.yml +24 -0
  33. package/.github/workflows/nightly-harness-review.lock.yml +1643 -0
  34. package/.github/workflows/nightly-harness-review.md +87 -0
  35. package/.github/workflows/nightly-harness-review.yml +63 -0
  36. package/.github/workflows/npm-publish.yml +49 -0
  37. package/.github/workflows/pr-context-comment.yml +138 -0
  38. package/.github/workflows/product-ci-go.yml +33 -0
  39. package/.github/workflows/product-ci-php.yml +39 -0
  40. package/.github/workflows/product-ci-python.yml +34 -0
  41. package/.github/workflows/product-ci-ruby.yml +35 -0
  42. package/.github/workflows/product-ci-ts.yml +37 -0
  43. package/.github/workflows/task-issue-label-sync.yml +50 -0
  44. package/.github/workflows/weekly-redteam.lock.yml +1571 -0
  45. package/.github/workflows/weekly-redteam.md +76 -0
  46. package/.github/zizmor.yml +11 -0
  47. package/AGENTS.md +54 -0
  48. package/LICENSE +21 -0
  49. package/README.md +366 -0
  50. package/config/stacks.json +55 -0
  51. package/docs/adoption.md +126 -0
  52. package/docs/arch.md +535 -0
  53. package/docs/auth-boundaries.md +16 -0
  54. package/docs/coding-agent-l1.md +152 -0
  55. package/docs/exceptions/README.md +25 -0
  56. package/docs/exceptions/TEMPLATE.md +8 -0
  57. package/docs/failure-taxonomy.md +23 -0
  58. package/docs/gh-aw-dogfood.md +109 -0
  59. package/docs/kpi-baseline.md +9 -0
  60. package/docs/nightly-harness-review.md +94 -0
  61. package/docs/operations.md +108 -0
  62. package/docs/publishing.md +79 -0
  63. package/docs/revert-playbook.md +44 -0
  64. package/docs/shared-config.md +30 -0
  65. package/docs/telemetry-artifacts.md +78 -0
  66. package/docs/telemetry-schema.md +60 -0
  67. package/evals/.score-baseline.json +6 -0
  68. package/evals/e2e-bench/README.md +28 -0
  69. package/evals/e2e-bench/manifest.json +16 -0
  70. package/evals/e2e-bench/tasks/e2e-001.yml +10 -0
  71. package/evals/e2e-bench/tasks/e2e-002.yml +11 -0
  72. package/evals/e2e-bench/tasks/e2e-003.yml +10 -0
  73. package/evals/e2e-bench/tasks/e2e-004.yml +14 -0
  74. package/evals/e2e-bench/tasks/e2e-005.yml +11 -0
  75. package/evals/e2e-bench/tasks/e2e-006.yml +10 -0
  76. package/evals/e2e-bench/tasks/e2e-007.yml +10 -0
  77. package/evals/e2e-bench/tasks/e2e-008.yml +10 -0
  78. package/evals/e2e-bench/tasks/e2e-009.yml +10 -0
  79. package/evals/trajectories/rubric.md +12 -0
  80. package/evals/trajectories/test_harness_conventions.py +271 -0
  81. package/infra/README.md +49 -0
  82. package/infra/langfuse/docker-compose.yml +25 -0
  83. package/infra/otel/collector-config.yml +24 -0
  84. package/infra/samples/gh-aw-dogfood-report.json +44 -0
  85. package/infra/samples/harness-review-routing-plan.json +19 -0
  86. package/infra/samples/harness-review-summary.json +61 -0
  87. package/infra/samples/telemetry-artifact.json +29 -0
  88. package/infra/samples/telemetry-payload.json +19 -0
  89. package/package.json +85 -0
  90. package/prompts/triager-classify.prompt.yml +10 -0
  91. package/sample/go/add.go +5 -0
  92. package/sample/go/add_test.go +9 -0
  93. package/sample/go/go.mod +3 -0
  94. package/sample/php/composer.json +26 -0
  95. package/sample/php/composer.lock +1881 -0
  96. package/sample/php/phpunit.xml +8 -0
  97. package/sample/php/src/Add.php +13 -0
  98. package/sample/php/tests/AddTest.php +16 -0
  99. package/sample/python/requirements-dev.txt +2 -0
  100. package/sample/python/src/__init__.py +0 -0
  101. package/sample/python/src/greet.py +3 -0
  102. package/sample/python/tests/conftest.py +4 -0
  103. package/sample/python/tests/test_greet.py +5 -0
  104. package/sample/ruby/.rubocop.yml +10 -0
  105. package/sample/ruby/Gemfile +6 -0
  106. package/sample/ruby/Gemfile.lock +58 -0
  107. package/sample/ruby/lib/add.rb +9 -0
  108. package/sample/ruby/spec/add_spec.rb +11 -0
  109. package/sample/ts/biome.json +6 -0
  110. package/sample/ts/package-lock.json +1763 -0
  111. package/sample/ts/package.json +15 -0
  112. package/sample/ts/src/add.ts +3 -0
  113. package/sample/ts/tests/add.test.ts +8 -0
  114. package/sample/ts/tsconfig.json +12 -0
  115. package/scripts/aggregate-harness-review.mjs +48 -0
  116. package/scripts/bootstrap-harness.sh +411 -0
  117. package/scripts/check-diff-size.mjs +46 -0
  118. package/scripts/check-e2e-manifest.mjs +35 -0
  119. package/scripts/check-eval-score-drift.mjs +31 -0
  120. package/scripts/check-gh-aw-dogfood-scope.mjs +51 -0
  121. package/scripts/check-issue-spec.mjs +215 -0
  122. package/scripts/check-l1-readiness.mjs +82 -0
  123. package/scripts/check-open-pr-limit.mjs +34 -0
  124. package/scripts/doctor.mjs +177 -0
  125. package/scripts/emit-gh-aw-dogfood-report.mjs +112 -0
  126. package/scripts/emit-telemetry-artifact.mjs +99 -0
  127. package/scripts/fetch-telemetry-artifacts.mjs +176 -0
  128. package/scripts/harness-drift-report.mjs +99 -0
  129. package/scripts/lib/bootstrap-copy.mjs +123 -0
  130. package/scripts/lib/ccsd-contract.mjs +212 -0
  131. package/scripts/lib/diff-size.mjs +103 -0
  132. package/scripts/lib/doctor-local.mjs +179 -0
  133. package/scripts/lib/e2e-manifest.mjs +76 -0
  134. package/scripts/lib/gh-aw-dogfood.mjs +293 -0
  135. package/scripts/lib/github-config.mjs +94 -0
  136. package/scripts/lib/harness-ci-fragments.mjs +98 -0
  137. package/scripts/lib/harness-review-routing.mjs +244 -0
  138. package/scripts/lib/harness-review.mjs +388 -0
  139. package/scripts/lib/issue-form-label-sync.mjs +56 -0
  140. package/scripts/lib/l1-readiness.mjs +258 -0
  141. package/scripts/lib/merge-harness-package.mjs +36 -0
  142. package/scripts/lib/npm-package.mjs +129 -0
  143. package/scripts/lib/setup-wizard.mjs +224 -0
  144. package/scripts/lib/stacks.mjs +138 -0
  145. package/scripts/lib/telemetry-artifact.mjs +253 -0
  146. package/scripts/lib/template-root.mjs +39 -0
  147. package/scripts/merge-harness-package.mjs +14 -0
  148. package/scripts/route-harness-review.mjs +168 -0
  149. package/scripts/run-e2e-bench.mjs +216 -0
  150. package/scripts/sdlc-gh-cli.mjs +91 -0
  151. package/scripts/select-eval-jobs.mjs +41 -0
  152. package/scripts/setup-github.mjs +242 -0
  153. package/scripts/setup-github.sh +4 -0
  154. package/scripts/setup-wizard.mjs +426 -0
  155. package/scripts/test-bootstrap-guidance-scenarios.mjs +94 -0
  156. package/scripts/test-diff-size-scenarios.mjs +88 -0
  157. package/scripts/test-doctor-scenarios.mjs +70 -0
  158. package/scripts/test-e2e-manifest-scenarios.mjs +65 -0
  159. package/scripts/test-gh-aw-dogfood-scenarios.mjs +74 -0
  160. package/scripts/test-harness-review-routing-scenarios.mjs +130 -0
  161. package/scripts/test-harness-review-scenarios.mjs +92 -0
  162. package/scripts/test-hooks-scenarios.mjs +44 -0
  163. package/scripts/test-issue-form-label-sync-scenarios.mjs +48 -0
  164. package/scripts/test-issue-spec-scenarios.mjs +258 -0
  165. package/scripts/test-l1-readiness-scenarios.mjs +204 -0
  166. package/scripts/test-merge-harness-package-scenarios.mjs +53 -0
  167. package/scripts/test-npm-package-scenarios.mjs +31 -0
  168. package/scripts/test-sdlc-gh-cli-scenarios.mjs +54 -0
  169. package/scripts/test-setup-github-scenarios.mjs +103 -0
  170. package/scripts/test-setup-wizard-scenarios.mjs +114 -0
  171. package/scripts/test-telemetry-artifact-scenarios.mjs +69 -0
  172. package/scripts/trim-harness-ci.mjs +18 -0
  173. package/scripts/validate-gh-aw-compile.mjs +64 -0
  174. package/scripts/validate-harness.mjs +199 -0
  175. package/scripts/validate-telemetry.mjs +21 -0
  176. package/scripts/verify-bootstrap-stacks.sh +192 -0
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Read stack catalog from config/stacks.json and infer stack/mode candidates.
4
+ */
5
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
6
+ import { join, dirname, basename, relative, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
10
+ const CATALOG_PATH = join(ROOT, "config/stacks.json");
11
+ export const DEFAULT_EXCLUDED_DIRS = new Set([
12
+ ".git",
13
+ "node_modules",
14
+ "vendor",
15
+ ".venv",
16
+ "dist",
17
+ "build",
18
+ "coverage",
19
+ "tmp",
20
+ "sample",
21
+ ]);
22
+
23
+ let cached;
24
+
25
+ export function loadStacks() {
26
+ if (!cached) {
27
+ cached = JSON.parse(readFileSync(CATALOG_PATH, "utf8"));
28
+ }
29
+ return cached.stacks;
30
+ }
31
+
32
+ export function getStack(id) {
33
+ const stack = loadStacks().find((s) => s.id === id);
34
+ if (!stack) {
35
+ throw new Error(`Unknown stack: ${id}`);
36
+ }
37
+ return stack;
38
+ }
39
+
40
+ export function stackIds() {
41
+ return loadStacks().map((s) => s.id);
42
+ }
43
+
44
+ function walk(dir, root, markersByName, nestedMatches) {
45
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
46
+ if (entry.isDirectory()) {
47
+ if (DEFAULT_EXCLUDED_DIRS.has(entry.name)) continue;
48
+ walk(join(dir, entry.name), root, markersByName, nestedMatches);
49
+ continue;
50
+ }
51
+
52
+ const stacks = markersByName.get(entry.name);
53
+ if (!stacks) continue;
54
+
55
+ const fullPath = join(dir, entry.name);
56
+ const relPath = relative(root, fullPath);
57
+ if (!relPath || relPath === entry.name) continue;
58
+
59
+ for (const stack of stacks) {
60
+ nestedMatches.push({ stackId: stack.id, path: relPath });
61
+ }
62
+ }
63
+ }
64
+
65
+ export function detectStackCandidates(repoPath) {
66
+ const repoRoot = resolve(repoPath);
67
+ const stacks = loadStacks();
68
+ const rootMatches = [];
69
+ const nestedMatches = [];
70
+
71
+ for (const stack of stacks) {
72
+ if (existsSync(join(repoRoot, stack.marker))) {
73
+ rootMatches.push({ stackId: stack.id, path: stack.marker });
74
+ }
75
+ }
76
+
77
+ if (existsSync(repoRoot) && statSync(repoRoot).isDirectory()) {
78
+ const markersByName = new Map();
79
+ for (const stack of stacks) {
80
+ const key = basename(stack.marker);
81
+ const values = markersByName.get(key) ?? [];
82
+ values.push(stack);
83
+ markersByName.set(key, values);
84
+ }
85
+ walk(repoRoot, repoRoot, markersByName, nestedMatches);
86
+ }
87
+
88
+ const rootStackIds = [...new Set(rootMatches.map((m) => m.stackId))];
89
+ const nestedStackIds = [...new Set(nestedMatches.map((m) => m.stackId))];
90
+ const suggested =
91
+ rootStackIds.length === 1
92
+ ? rootStackIds[0]
93
+ : rootStackIds.length === 0 && nestedStackIds.length === 1 && nestedStackIds[0] !== "ts"
94
+ ? nestedStackIds[0]
95
+ : null;
96
+
97
+ return {
98
+ repoRoot,
99
+ rootMatches,
100
+ nestedMatches,
101
+ suggested,
102
+ ambiguous:
103
+ rootStackIds.length > 1 ||
104
+ nestedStackIds.length > 1 ||
105
+ (rootStackIds.length === 0 && nestedStackIds.length === 1 && nestedStackIds[0] === "ts"),
106
+ };
107
+ }
108
+
109
+ export function inspectRepoMode(repoPath) {
110
+ const target = resolve(repoPath);
111
+ if (!existsSync(target)) {
112
+ return { suggested: "new", reason: "target directory does not exist", ambiguous: false };
113
+ }
114
+ if (!statSync(target).isDirectory()) {
115
+ throw new Error(`Not a directory: ${target}`);
116
+ }
117
+
118
+ const entries = readdirSync(target).filter((entry) => entry !== ".DS_Store");
119
+ if (entries.length === 0) {
120
+ return { suggested: "new", reason: "target directory is empty", ambiguous: false };
121
+ }
122
+
123
+ const nonGitEntries = entries.filter((entry) => entry !== ".git");
124
+ if (nonGitEntries.length === 0) {
125
+ return { suggested: null, reason: "target contains only .git", ambiguous: true };
126
+ }
127
+
128
+ const rootMarkers = loadStacks().filter((stack) => existsSync(join(target, stack.marker)));
129
+ if (rootMarkers.length > 0) {
130
+ return { suggested: "existing", reason: "detected existing stack marker files", ambiguous: false };
131
+ }
132
+
133
+ if (nonGitEntries.every((entry) => /^README(\..+)?$/i.test(entry) || entry === ".gitignore")) {
134
+ return { suggested: null, reason: "target looks like a seed repository", ambiguous: true };
135
+ }
136
+
137
+ return { suggested: "existing", reason: "target already contains files", ambiguous: false };
138
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Canonical telemetry artifact shape for inner-loop workflows.
3
+ * See docs/telemetry-schema.md and docs/telemetry-artifacts.md.
4
+ */
5
+
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { execSync } from "node:child_process";
9
+ import { extractClosingIssueNumbers } from "./ccsd-contract.mjs";
10
+ import { aggregateDiffStats, parseLabelInput, resolveAutonomyLevel } from "./diff-size.mjs";
11
+
12
+ export const TELEMETRY_SCHEMA_VERSION = "1";
13
+ export const ARTIFACT_DIR = "telemetry-artifacts";
14
+
15
+ /** Required payload fields per docs/telemetry-schema.md */
16
+ export const TELEMETRY_REQUIRED_FIELDS = [
17
+ "task_id",
18
+ "pr_number",
19
+ "repo",
20
+ "agent_type",
21
+ "execution_mode",
22
+ "model",
23
+ "task_class",
24
+ "autonomy_level",
25
+ "tool_calls",
26
+ "retry_count",
27
+ "wall_failure_type",
28
+ "cost",
29
+ "elapsed_time",
30
+ "changed_files",
31
+ "diff_loc",
32
+ "final_outcome",
33
+ "review_outcome",
34
+ ];
35
+
36
+ /** Fields commonly unavailable until Langfuse / agent runtime wiring exists */
37
+ export const TELEMETRY_BEST_EFFORT_FIELDS = [
38
+ "agent_type",
39
+ "execution_mode",
40
+ "model",
41
+ "tool_calls",
42
+ "cost",
43
+ "elapsed_time",
44
+ "review_outcome",
45
+ ];
46
+
47
+ const WALL_FAILURE_PATTERNS = [
48
+ [/diff[- ]?size/i, "diff-size"],
49
+ [/issue[- ]?spec/i, "lint"],
50
+ [/zizmor|codeql|security/i, "security"],
51
+ [/safe[- ]?output/i, "safe-output"],
52
+ [/product-ci/i, "test"],
53
+ [/type(check)?/i, "type"],
54
+ [/lint/i, "lint"],
55
+ [/test/i, "test"],
56
+ ];
57
+
58
+ const PLACEHOLDER_DEFAULTS = {
59
+ agent_type: "n/a",
60
+ execution_mode: "ci",
61
+ model: "n/a",
62
+ task_class: "unknown",
63
+ autonomy_level: "L1",
64
+ tool_calls: -1,
65
+ retry_count: 0,
66
+ wall_failure_type: "",
67
+ cost: -1,
68
+ elapsed_time: -1,
69
+ changed_files: 0,
70
+ diff_loc: 0,
71
+ final_outcome: "in_progress",
72
+ review_outcome: "pending",
73
+ };
74
+
75
+ /**
76
+ * @param {string} input
77
+ * @returns {string}
78
+ */
79
+ export function resolveTaskClass(labels) {
80
+ const taskLabel = labels.find((l) => l.startsWith("task:"));
81
+ return taskLabel ? taskLabel.replace(/^task:/, "") : PLACEHOLDER_DEFAULTS.task_class;
82
+ }
83
+
84
+ /**
85
+ * @param {string} input
86
+ * @returns {number}
87
+ */
88
+ export function resolveRetryCount(labels) {
89
+ const retryLabel = labels.find((l) => l.startsWith("retry:"));
90
+ if (!retryLabel) return 0;
91
+ const count = parseInt(retryLabel.split(":")[1], 10);
92
+ return Number.isFinite(count) ? count : 0;
93
+ }
94
+
95
+ /**
96
+ * @param {string | number | undefined | null} prBody
97
+ * @param {number} prNumber
98
+ * @returns {string}
99
+ */
100
+ export function resolveTaskId(prBody, prNumber) {
101
+ const linked = extractClosingIssueNumbers(String(prBody || ""));
102
+ if (linked.length === 1) return String(linked[0]);
103
+ if (linked.length > 1) return String(linked[0]);
104
+ if (prNumber) return `pr-${prNumber}`;
105
+ return "unknown";
106
+ }
107
+
108
+ /**
109
+ * Map failed CI job / check names to telemetry wall_failure_type.
110
+ * @param {string} name
111
+ * @returns {string}
112
+ */
113
+ export function mapNameToWallFailureType(name) {
114
+ const text = String(name || "");
115
+ if (!text) return "";
116
+ for (const [pattern, wallType] of WALL_FAILURE_PATTERNS) {
117
+ if (pattern.test(text)) return wallType;
118
+ }
119
+ return "";
120
+ }
121
+
122
+ /**
123
+ * @param {Record<string, { result?: string }>} jobResults
124
+ * @returns {string}
125
+ */
126
+ export function wallFailureTypeFromJobResults(jobResults = {}) {
127
+ const failed = Object.entries(jobResults)
128
+ .filter(([, job]) => job?.result === "failure")
129
+ .map(([name]) => name);
130
+
131
+ for (const name of failed) {
132
+ const mapped = mapNameToWallFailureType(name);
133
+ if (mapped) return mapped;
134
+ }
135
+ return failed.length ? mapNameToWallFailureType(failed[0]) || "test" : "";
136
+ }
137
+
138
+ /**
139
+ * @param {Record<string, unknown>} overrides
140
+ * @returns {{ payload: Record<string, unknown>, placeholders: string[] }}
141
+ */
142
+ export function buildTelemetryPayload(overrides = {}) {
143
+ const labels = parseLabelInput(overrides.labels ?? overrides.PR_LABELS ?? "");
144
+ const prNumber = Number(overrides.pr_number ?? overrides.PR_NUMBER ?? 0) || 0;
145
+ const prBody = overrides.pr_body ?? overrides.PR_BODY ?? "";
146
+
147
+ const payload = {
148
+ task_id: resolveTaskId(prBody, prNumber),
149
+ pr_number: prNumber,
150
+ repo: String(overrides.repo ?? overrides.GITHUB_REPOSITORY ?? "unknown/unknown"),
151
+ agent_type: String(overrides.agent_type ?? PLACEHOLDER_DEFAULTS.agent_type),
152
+ execution_mode: String(overrides.execution_mode ?? PLACEHOLDER_DEFAULTS.execution_mode),
153
+ model: String(overrides.model ?? PLACEHOLDER_DEFAULTS.model),
154
+ task_class: String(overrides.task_class ?? resolveTaskClass(labels)),
155
+ autonomy_level: String(
156
+ overrides.autonomy_level ?? resolveAutonomyLevel(labels) ?? PLACEHOLDER_DEFAULTS.autonomy_level,
157
+ ),
158
+ tool_calls: Number(overrides.tool_calls ?? PLACEHOLDER_DEFAULTS.tool_calls),
159
+ retry_count: Number(
160
+ overrides.retry_count ?? resolveRetryCount(labels) ?? PLACEHOLDER_DEFAULTS.retry_count,
161
+ ),
162
+ wall_failure_type: String(
163
+ overrides.wall_failure_type ?? overrides.WALL_FAILURE_TYPE ?? PLACEHOLDER_DEFAULTS.wall_failure_type,
164
+ ),
165
+ cost: Number(overrides.cost ?? PLACEHOLDER_DEFAULTS.cost),
166
+ elapsed_time: Number(overrides.elapsed_time ?? PLACEHOLDER_DEFAULTS.elapsed_time),
167
+ changed_files: Number(overrides.changed_files ?? PLACEHOLDER_DEFAULTS.changed_files),
168
+ diff_loc: Number(overrides.diff_loc ?? PLACEHOLDER_DEFAULTS.diff_loc),
169
+ final_outcome: String(overrides.final_outcome ?? PLACEHOLDER_DEFAULTS.final_outcome),
170
+ review_outcome: String(overrides.review_outcome ?? PLACEHOLDER_DEFAULTS.review_outcome),
171
+ };
172
+
173
+ const placeholders = TELEMETRY_BEST_EFFORT_FIELDS.filter((field) => {
174
+ if (overrides[field] !== undefined && overrides[field] !== null) return false;
175
+ const upper = field.toUpperCase();
176
+ if (overrides[upper] !== undefined && overrides[upper] !== null) return false;
177
+ return true;
178
+ });
179
+
180
+ return { payload, placeholders };
181
+ }
182
+
183
+ /**
184
+ * @param {Record<string, unknown>} options
185
+ * @returns {Record<string, unknown>}
186
+ */
187
+ export function buildTelemetryArtifact(options = {}) {
188
+ const { payload, placeholders } = buildTelemetryPayload(options);
189
+ return {
190
+ schema_version: TELEMETRY_SCHEMA_VERSION,
191
+ emitted_at: new Date().toISOString(),
192
+ source: String(options.source ?? options.TELEMETRY_SOURCE ?? "unknown"),
193
+ workflow: options.workflow ?? options.GITHUB_WORKFLOW ?? null,
194
+ workflow_run_id: Number(options.workflow_run_id ?? options.GITHUB_RUN_ID ?? 0) || null,
195
+ run_attempt: Number(options.run_attempt ?? options.GITHUB_RUN_ATTEMPT ?? 1) || 1,
196
+ event_name: options.event_name ?? options.GITHUB_EVENT_NAME ?? null,
197
+ placeholders,
198
+ payload,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * @param {{ source: string, prNumber?: number, workflowRunId?: number | string }} params
204
+ * @returns {string}
205
+ */
206
+ export function artifactFilename({ source, prNumber = 0, workflowRunId = 0 }) {
207
+ const safeSource = String(source).replace(/[^a-z0-9-]+/gi, "-");
208
+ const prPart = prNumber ? `pr${prNumber}` : "no-pr";
209
+ return `${safeSource}-${prPart}-run${workflowRunId}.json`;
210
+ }
211
+
212
+ /**
213
+ * @param {Record<string, unknown>} artifact
214
+ * @param {{ outDir?: string, filename?: string }} [opts]
215
+ * @returns {string} absolute path written
216
+ */
217
+ export function writeTelemetryArtifact(artifact, opts = {}) {
218
+ const outDir = opts.outDir ?? ARTIFACT_DIR;
219
+ mkdirSync(outDir, { recursive: true });
220
+ const filename =
221
+ opts.filename ??
222
+ artifactFilename({
223
+ source: artifact.source,
224
+ prNumber: artifact.payload?.pr_number,
225
+ workflowRunId: artifact.workflow_run_id,
226
+ });
227
+ const path = join(outDir, filename);
228
+ writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
229
+ return path;
230
+ }
231
+
232
+ /**
233
+ * @param {string} baseRef e.g. origin/main
234
+ * @returns {{ changed_files: number, diff_loc: number }}
235
+ */
236
+ export function diffStatsFromGit(baseRef = "origin/main") {
237
+ const range = `${baseRef}...HEAD`;
238
+ try {
239
+ const numstat = execSync(`git diff --numstat ${range}`, { encoding: "utf8" });
240
+ const stats = aggregateDiffStats(numstat);
241
+ return { changed_files: stats.files, diff_loc: stats.loc };
242
+ } catch {
243
+ return { changed_files: 0, diff_loc: 0 };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * @param {Record<string, unknown>} payload
249
+ * @returns {string[]}
250
+ */
251
+ export function missingRequiredFields(payload) {
252
+ return TELEMETRY_REQUIRED_FIELDS.filter((key) => payload[key] === undefined || payload[key] === null);
253
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /** @param {string} dir */
7
+ export function isTemplateRoot(dir) {
8
+ return (
9
+ existsSync(join(dir, "scripts/bootstrap-harness.sh")) &&
10
+ existsSync(join(dir, "config/stacks.json")) &&
11
+ existsSync(join(dir, "scripts/setup-wizard.mjs"))
12
+ );
13
+ }
14
+
15
+ /**
16
+ * Resolve the harness template root (source of bootstrap assets).
17
+ * @param {{ fromModule?: string }} [options]
18
+ */
19
+ export function resolveTemplateRoot(options = {}) {
20
+ const envRoot = process.env.SDLCGH_TEMPLATE_ROOT?.trim();
21
+ if (envRoot) {
22
+ if (!isTemplateRoot(envRoot)) {
23
+ throw new Error(`SDLCGH_TEMPLATE_ROOT is not a valid harness template: ${envRoot}`);
24
+ }
25
+ return envRoot;
26
+ }
27
+
28
+ if (options.fromModule) {
29
+ const fromLib = join(dirname(fileURLToPath(options.fromModule)), "../..");
30
+ if (isTemplateRoot(fromLib)) return fromLib;
31
+ }
32
+
33
+ const fromCwd = process.cwd();
34
+ if (isTemplateRoot(fromCwd)) return fromCwd;
35
+
36
+ throw new Error(
37
+ "Unable to locate harness template root. Run from the sdlc-gh package, set SDLCGH_TEMPLATE_ROOT, or use `npx @guilz-dev/sdlc-gh init`.",
38
+ );
39
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { mergeHarnessPackageJson } from "./lib/merge-harness-package.mjs";
4
+
5
+ const [templatePathArg, targetPathArg] = process.argv.slice(2);
6
+ if (!templatePathArg || !targetPathArg || process.argv.includes("--help") || process.argv.includes("-h")) {
7
+ console.log(`Usage: merge-harness-package.mjs <template-package.json> <target-package.json>`);
8
+ process.exit(templatePathArg && !targetPathArg ? 1 : 0);
9
+ }
10
+
11
+ const templatePath = resolve(templatePathArg);
12
+ const targetPath = resolve(targetPathArg);
13
+ const result = mergeHarnessPackageJson(templatePath, targetPath);
14
+ console.log(`${result.action} ${targetPath} (${result.scriptCount} harness scripts)`);
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Route nightly harness review summary into GitHub issues (#4).
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import {
11
+ applyRoutingPlanDryRun,
12
+ bodyHasRoutingMarker,
13
+ buildRoutingPlan,
14
+ } from "./lib/harness-review-routing.mjs";
15
+ import { REVIEW_OUT_DIR } from "./lib/harness-review.mjs";
16
+
17
+ function ghJson(cmd) {
18
+ const out = execSync(cmd, {
19
+ encoding: "utf8",
20
+ env: process.env,
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ });
23
+ return JSON.parse(out);
24
+ }
25
+
26
+ function parseRepo(repo) {
27
+ const [owner, name] = String(repo).split("/");
28
+ if (!owner || !name) throw new Error(`Invalid GITHUB_REPOSITORY: ${repo}`);
29
+ return { owner, name };
30
+ }
31
+
32
+ function ghApi(method, path, payload = null) {
33
+ if (payload) {
34
+ const file = join(tmpdir(), `gh-api-${Date.now()}.json`);
35
+ writeFileSync(file, JSON.stringify(payload));
36
+ try {
37
+ const verb = method === "GET" ? "" : `-X ${method} `;
38
+ return ghJson(`gh api ${verb}${path} --input ${file}`);
39
+ } finally {
40
+ unlinkSync(file);
41
+ }
42
+ }
43
+ return ghJson(`gh api ${path}`);
44
+ }
45
+
46
+ function listOpenRoutedIssues(owner, name) {
47
+ const issues = ghApi("GET", `repos/${owner}/${name}/issues?state=open&per_page=100`);
48
+ return (Array.isArray(issues) ? issues : []).filter((issue) =>
49
+ String(issue.body || "").includes("harness-routing-key:"),
50
+ );
51
+ }
52
+
53
+ function createIssue(owner, name, action) {
54
+ return ghApi("POST", `repos/${owner}/${name}/issues`, {
55
+ title: action.title,
56
+ body: action.body,
57
+ labels: action.labels ?? [],
58
+ });
59
+ }
60
+
61
+ function updateIssue(owner, name, issueNumber, action) {
62
+ return ghApi("PATCH", `repos/${owner}/${name}/issues/${issueNumber}`, {
63
+ title: action.title,
64
+ body: action.body,
65
+ });
66
+ }
67
+
68
+ function main() {
69
+ const repo = process.env.GITHUB_REPOSITORY;
70
+ if (!repo) {
71
+ console.error("::error::GITHUB_REPOSITORY is required");
72
+ process.exit(1);
73
+ }
74
+
75
+ const dryRun = process.env.HARNESS_ROUTING_DRY_RUN === "1";
76
+ if (!dryRun && !process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) {
77
+ console.error("::error::GH_TOKEN or GITHUB_TOKEN is required");
78
+ process.exit(1);
79
+ }
80
+
81
+ const reviewDir = process.env.HARNESS_REVIEW_OUT_DIR || REVIEW_OUT_DIR;
82
+ const summaryPath =
83
+ process.env.HARNESS_REVIEW_SUMMARY_PATH || join(reviewDir, "harness-review-summary.json");
84
+ if (!existsSync(summaryPath)) {
85
+ console.error(`::error::Missing summary: ${summaryPath}`);
86
+ process.exit(1);
87
+ }
88
+
89
+ const summary = JSON.parse(readFileSync(summaryPath, "utf8"));
90
+ const plan = buildRoutingPlan(summary);
91
+ const { owner, name } = parseRepo(repo);
92
+
93
+ mkdirSync(reviewDir, { recursive: true });
94
+ const planPath = join(reviewDir, "harness-review-routing-plan.json");
95
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
96
+ console.log(`Wrote ${planPath}`);
97
+
98
+ if (!(plan.actions ?? []).length) {
99
+ console.log("No routing actions (thresholds not met)");
100
+ console.log(`::notice::routing_actions=0`);
101
+ return;
102
+ }
103
+
104
+ if (dryRun) {
105
+ const preview = applyRoutingPlanDryRun(plan, { existingIssues: [] });
106
+ const previewPath = join(reviewDir, "harness-review-routing-preview.json");
107
+ writeFileSync(previewPath, `${JSON.stringify(preview, null, 2)}\n`, "utf8");
108
+ console.log(`Dry run: ${plan.actions.length} action(s) — see ${previewPath}`);
109
+ console.log(`::notice::routing_actions=${plan.actions.length} dry_run=true`);
110
+ return;
111
+ }
112
+
113
+ let existingIssues;
114
+ try {
115
+ existingIssues = listOpenRoutedIssues(owner, name);
116
+ } catch (error) {
117
+ console.error(`::error::Failed to list routed issues: ${error.message}`);
118
+ process.exit(1);
119
+ }
120
+ const results = [];
121
+
122
+ for (const action of plan.actions) {
123
+ const match = existingIssues.find((issue) =>
124
+ bodyHasRoutingMarker(issue.body, action.dedupe_key),
125
+ );
126
+ if (match) {
127
+ const updated = updateIssue(owner, name, match.number, action);
128
+ results.push({
129
+ dedupe_key: action.dedupe_key,
130
+ operation: "update_issue",
131
+ issue_number: updated.number,
132
+ url: updated.html_url,
133
+ });
134
+ console.log(`Updated issue #${updated.number} (${action.kind})`);
135
+ } else {
136
+ const created = createIssue(owner, name, action);
137
+ results.push({
138
+ dedupe_key: action.dedupe_key,
139
+ operation: "create_issue",
140
+ issue_number: created.number,
141
+ url: created.html_url,
142
+ });
143
+ console.log(`Created issue #${created.number} (${action.kind})`);
144
+ }
145
+ }
146
+
147
+ const resultsPath = join(reviewDir, "harness-review-routing-results.json");
148
+ writeFileSync(
149
+ resultsPath,
150
+ `${JSON.stringify({ generated_at: new Date().toISOString(), results }, null, 2)}\n`,
151
+ "utf8",
152
+ );
153
+ console.log(`Wrote ${resultsPath}`);
154
+ console.log(`::notice::routing_actions=${results.length}`);
155
+
156
+ const stepSummaryPath = process.env.GITHUB_STEP_SUMMARY;
157
+ if (stepSummaryPath && results.length) {
158
+ const lines = ["", "## Issue routing", ""];
159
+ for (const result of results) {
160
+ lines.push(`- ${result.operation}: [#${result.issue_number}](${result.url})`);
161
+ }
162
+ appendFileSync(stepSummaryPath, `${lines.join("\n")}\n`, "utf8");
163
+ }
164
+ }
165
+
166
+ const isMain =
167
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
168
+ if (isMain) main();