@theagilemonkeys/facility 0.3.1

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 (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +68 -0
  3. package/bin/facility.mjs +10 -0
  4. package/modules/README.md +35 -0
  5. package/modules/ai-queryability/agents/queryability-reviewer.md +35 -0
  6. package/modules/ai-queryability/module.json +9 -0
  7. package/modules/ai-queryability/standard-section.md +22 -0
  8. package/modules/analytics/agents/analytics-reviewer.md +32 -0
  9. package/modules/analytics/commands/add-telemetry.md +23 -0
  10. package/modules/analytics/module.json +10 -0
  11. package/modules/analytics/standard-section.md +23 -0
  12. package/modules/database/agents/data-security-reviewer.md +38 -0
  13. package/modules/database/commands/new-migration.md +24 -0
  14. package/modules/database/guards/migration-versions.mjs +41 -0
  15. package/modules/database/guards/migrations-immutable.mjs +57 -0
  16. package/modules/database/hooks/protect-migrations.fragment.mjs +10 -0
  17. package/modules/database/module.json +25 -0
  18. package/modules/database/standard-section.md +20 -0
  19. package/modules/design-system/agents/design-reviewer.md +37 -0
  20. package/modules/design-system/module.json +9 -0
  21. package/modules/design-system/standard-section.md +15 -0
  22. package/package.json +42 -0
  23. package/src/add.mjs +77 -0
  24. package/src/cli.mjs +352 -0
  25. package/src/detect.mjs +127 -0
  26. package/src/doctor.mjs +582 -0
  27. package/src/init.mjs +572 -0
  28. package/src/instance.mjs +114 -0
  29. package/src/platform-admin.mjs +1542 -0
  30. package/src/platform-config.mjs +39 -0
  31. package/src/platform.mjs +1759 -0
  32. package/src/prompts.mjs +64 -0
  33. package/src/render.mjs +66 -0
  34. package/src/ui.mjs +30 -0
  35. package/templates/claude/agents/security-reviewer.md +41 -0
  36. package/templates/claude/agents/standards-reviewer.md +31 -0
  37. package/templates/claude/commands/open-pr.md +21 -0
  38. package/templates/claude/commands/verify.md +16 -0
  39. package/templates/claude/hooks/protect-branch.mjs +58 -0
  40. package/templates/claude/hooks/protect-files.mjs +35 -0
  41. package/templates/claude/settings.json +71 -0
  42. package/templates/claude/skills/maintainable-software/SKILL.md +67 -0
  43. package/templates/claude/skills/reviewing-to-standard/SKILL.md +49 -0
  44. package/templates/claude/skills/working-to-standard/SKILL.md +45 -0
  45. package/templates/delivery/verify.mjs +157 -0
  46. package/templates/doctor/resolve.mjs +144 -0
  47. package/templates/guards/README.md +30 -0
  48. package/templates/guards/_kit.mjs +81 -0
  49. package/templates/guards/actions-pinned.mjs +38 -0
  50. package/templates/guards/run.mjs +111 -0
  51. package/templates/guards/watchtower-locked.mjs +66 -0
  52. package/templates/prompts/address-review.md +14 -0
  53. package/templates/prompts/architect.md +62 -0
  54. package/templates/prompts/builder.md +71 -0
  55. package/templates/prompts/doctor.md +64 -0
  56. package/templates/prompts/review.md +14 -0
  57. package/templates/prompts/sweep.md +75 -0
  58. package/templates/receipts/collect.mjs +289 -0
  59. package/templates/review/finalize.mjs +38 -0
  60. package/templates/scripts/move-board-status.sh +155 -0
  61. package/templates/security/sync-findings.mjs +226 -0
  62. package/templates/standard/STANDARD.md +141 -0
  63. package/templates/standard/agents-block.md +25 -0
  64. package/templates/watchtower/budgets.json +12 -0
  65. package/templates/watchtower/canary.mjs +216 -0
  66. package/templates/watchtower/health.mjs +148 -0
  67. package/templates/watchtower/outcomes.mjs +188 -0
  68. package/templates/workflows/facility-address-review.yml +153 -0
  69. package/templates/workflows/facility-canary.yml +61 -0
  70. package/templates/workflows/facility-codex.yml +326 -0
  71. package/templates/workflows/facility-crew.yml +350 -0
  72. package/templates/workflows/facility-doctor.yml +155 -0
  73. package/templates/workflows/facility-review.yml +134 -0
  74. package/templates/workflows/facility-security-sweep.yml +204 -0
  75. package/templates/workflows/facility-watchtower.yml +87 -0
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // Generated by facility — https://github.com/theam/facility
3
+ //
4
+ // Weekly synthetic canary. Flies the REAL pipeline: posts the canonical
5
+ // /architect probe on the pinned canary issue, then asserts the crew run
6
+ // appears, succeeds, and replies. Any broken link turns this workflow red,
7
+ // and the health monitor (which watches facility-canary) raises it in the
8
+ // incident issue the next morning.
9
+ //
10
+ // Authorization is by MESSAGE, not by sender: the crew workflow admits the
11
+ // canary bot only for a comment that is byte-identical (SHA-256, CR-stripped)
12
+ // to CANARY_PROBE_BODY below, on an agent-canary-labeled issue, resolving to
13
+ // /architect. Within the crew trigger that blocks attacker-chosen
14
+ // instructions, but the hash is an instruction-content gate, not a replay or
15
+ // cost boundary: the token can post the fixed probe repeatedly, and it can
16
+ // exercise any other GitHub permissions it carries. The generated workflow
17
+ // narrows its minted token to Issues: write. Keep the backing App dedicated to
18
+ // canary repositories with Issues: read and write as its only requested
19
+ // repository permission, because its private key can mint tokens up to the
20
+ // App's installed permissions. The pinned hash in facility-crew.yml is kept in
21
+ // sync by the watchtower-locked guard.
22
+ //
23
+ // The probe must be posted with a GitHub App or PAT token (CANARY_COMMENT_TOKEN):
24
+ // comments posted with the workflow's own GITHUB_TOKEN trigger no workflows
25
+ // at all (GitHub's recursion guard) — the canary would test nothing.
26
+ // Env: GH_TOKEN, GITHUB_REPOSITORY, CANARY_COMMENT_TOKEN (App/PAT).
27
+ import { execFileSync } from "node:child_process";
28
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { verifyReceipt } from "../receipts/collect.mjs";
32
+
33
+ export const CANARY_PROBE_BODY = [
34
+ "/architect",
35
+ "",
36
+ "Facility canary probe. This is a scheduled synthetic check of the agent",
37
+ "pipeline, authorized by message hash. Reply with a one-paragraph health",
38
+ "summary of this repository's facility setup (crew workflows present,",
39
+ "STANDARD.md readable, guards runnable) and stop. Do not plan features, do",
40
+ "not read beyond what the summary needs, and do not modify anything.",
41
+ ].join("\n");
42
+
43
+ const CREW_WORKFLOW = "facility-crew";
44
+ const POLL_SECONDS = 30;
45
+ const TIMEOUT_MINUTES = 45;
46
+
47
+ async function main() {
48
+ const repo = process.env.GITHUB_REPOSITORY;
49
+ if (!repo) throw new Error("GITHUB_REPOSITORY is required");
50
+ const token = process.env.CANARY_COMMENT_TOKEN;
51
+ if (!token) {
52
+ console.log(
53
+ "::notice::CANARY_COMMENT_TOKEN not configured — canary skipped. Mint it from a GitHub App so the probe comment triggers workflows.",
54
+ );
55
+ return;
56
+ }
57
+
58
+ const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
59
+ const api = async (path, init = {}) => {
60
+ const response = await fetch(`https://api.github.com/repos/${repo}/${path}`, {
61
+ ...init,
62
+ headers: {
63
+ authorization: `Bearer ${token}`,
64
+ accept: "application/vnd.github+json",
65
+ ...(init.headers ?? {}),
66
+ },
67
+ });
68
+ if (!response.ok) throw new Error(`${path} → ${response.status}: ${await response.text()}`);
69
+ return response.json();
70
+ };
71
+
72
+ // 1. Find (or create) the pinned canary fixture issue.
73
+ try {
74
+ gh([
75
+ "label",
76
+ "create",
77
+ "agent-canary",
78
+ "--force",
79
+ "--color",
80
+ "FFD923",
81
+ "--description",
82
+ "facility canary fixture",
83
+ ]);
84
+ } catch {}
85
+ const issues = JSON.parse(
86
+ gh([
87
+ "issue",
88
+ "list",
89
+ "--label",
90
+ "agent-canary",
91
+ "--state",
92
+ "open",
93
+ "--json",
94
+ "number",
95
+ "--limit",
96
+ "1",
97
+ ]),
98
+ );
99
+ let issueNumber = issues[0]?.number;
100
+ if (!issueNumber) {
101
+ const createdUrl = gh([
102
+ "issue",
103
+ "create",
104
+ "--title",
105
+ "Facility canary",
106
+ "--label",
107
+ "agent-canary",
108
+ "--body",
109
+ "Pinned fixture issue for the weekly synthetic canary. The probe comments below are posted by the canary workflow; do not comment agent commands here yourself.",
110
+ ]).trim();
111
+ issueNumber = Number(createdUrl.split("/").pop());
112
+ if (!issueNumber) throw new Error(`could not parse issue number from: ${createdUrl}`);
113
+ }
114
+
115
+ // 2. Post the canonical probe with the App token (so it triggers workflows).
116
+ const startedAt = new Date().toISOString();
117
+ await api(`issues/${issueNumber}/comments`, {
118
+ method: "POST",
119
+ body: JSON.stringify({ body: CANARY_PROBE_BODY }),
120
+ });
121
+ console.log(`probe posted on #${issueNumber} at ${startedAt}`);
122
+
123
+ // 3. Assert the crew run appears and succeeds.
124
+ const deadline = Date.now() + TIMEOUT_MINUTES * 60_000;
125
+ let run = null;
126
+ while (Date.now() < deadline) {
127
+ const { workflow_runs: runs } = JSON.parse(
128
+ gh([
129
+ "api",
130
+ `repos/${repo}/actions/runs?event=issue_comment&created=>${startedAt}&per_page=20`,
131
+ ]),
132
+ );
133
+ run = runs.find((r) => r.name === CREW_WORKFLOW) ?? null;
134
+ if (run && run.status === "completed") break;
135
+ await new Promise((resolve) => setTimeout(resolve, POLL_SECONDS * 1000));
136
+ }
137
+ if (!run)
138
+ throw new Error(
139
+ `no ${CREW_WORKFLOW} run appeared within ${TIMEOUT_MINUTES} minutes — the trigger chain is broken`,
140
+ );
141
+ if (run.conclusion !== "success")
142
+ throw new Error(`crew run concluded '${run.conclusion}': ${run.html_url}`);
143
+
144
+ // 4. Download and verify the receipt produced by THIS exact crew run. A
145
+ // green workflow conclusion without its signed evidence is not a green
146
+ // agent flight.
147
+ const artifactName = `facility-run-receipt-${run.id}-crew`;
148
+ const receiptDir = mkdtempSync(join(tmpdir(), "facility-canary-receipt-"));
149
+ let receipt;
150
+ let receiptPath;
151
+ try {
152
+ gh([
153
+ "run",
154
+ "download",
155
+ String(run.id),
156
+ "--repo",
157
+ repo,
158
+ "--name",
159
+ artifactName,
160
+ "--dir",
161
+ receiptDir,
162
+ ]);
163
+ receiptPath = findFile(receiptDir, "facility-run.json");
164
+ if (!receiptPath) throw new Error(`${artifactName} did not contain facility-run.json`);
165
+ receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
166
+ if (!verifyReceipt(receipt))
167
+ throw new Error(`${artifactName} failed its SHA-256 integrity check`);
168
+ if (
169
+ receipt.provider !== "claude_code" ||
170
+ receipt.mode !== "architect" ||
171
+ receipt.result !== "succeeded" ||
172
+ receipt.github?.issue !== issueNumber ||
173
+ !String(receipt.run_id ?? "").startsWith(`${run.id}:`)
174
+ ) {
175
+ throw new Error(`${artifactName} does not describe this architect canary run`);
176
+ }
177
+ gh(["attestation", "verify", receiptPath, "--repo", repo]);
178
+ } finally {
179
+ rmSync(receiptDir, { recursive: true, force: true });
180
+ }
181
+
182
+ // 5. Assert a reply landed on the issue after the probe.
183
+ const comments = JSON.parse(
184
+ gh(["api", `repos/${repo}/issues/${issueNumber}/comments?per_page=100`]),
185
+ );
186
+ const reply = comments.find((c) => c.created_at > startedAt && c.body !== CANARY_PROBE_BODY);
187
+ if (!reply)
188
+ throw new Error(
189
+ `crew run succeeded but no reply landed on #${issueNumber} — the reply chain is broken`,
190
+ );
191
+
192
+ console.log(
193
+ `canary green: run ${run.html_url} · receipt ${receipt.integrity.payload_sha256} (attested) · reply ${reply.html_url}`,
194
+ );
195
+ }
196
+
197
+ function findFile(dir, name) {
198
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
199
+ const path = join(dir, entry.name);
200
+ if (entry.isDirectory()) {
201
+ const nested = findFile(path, name);
202
+ if (nested) return nested;
203
+ } else if (entry.name === name) {
204
+ return path;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ // Import-safe: the watchtower-locked guard imports CANARY_PROBE_BODY without running the probe.
211
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) {
212
+ main().catch((error) => {
213
+ console.error(`::error::${error.message}`);
214
+ process.exit(1);
215
+ });
216
+ }
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // Generated by facility — https://github.com/theam/facility
3
+ //
4
+ // Daily health monitor. Watches the watchmen: pulls the last 24h of facility
5
+ // workflow runs straight from the GitHub API (deliberately NOT from any
6
+ // telemetry pipeline the facility itself writes — the monitor must not
7
+ // depend on what it monitors), checks failure streaks and run budgets, and
8
+ // maintains a single incident issue. The monitor run itself goes RED when
9
+ // the facility is unhealthy, so the Actions tab is the at-a-glance signal.
10
+ //
11
+ // This workflow is intentionally NOT in its own watchlist (no self-recursion).
12
+ // Budgets live next to this file in budgets.json — a reviewed file, not a
13
+ // dashboard setting.
14
+ // Env: GH_TOKEN (rw issues), GITHUB_REPOSITORY.
15
+ import { execFileSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const repo = process.env.GITHUB_REPOSITORY;
21
+ if (!repo) throw new Error("GITHUB_REPOSITORY is required");
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+
24
+ const WATCHLIST = [
25
+ "facility-crew",
26
+ "facility-codex",
27
+ "facility-review",
28
+ "facility-address-review",
29
+ "facility-doctor",
30
+ "facility-security-sweep",
31
+ "facility-canary",
32
+ ];
33
+
34
+ let budgets = { maxDailyFailures: { "*": 3 }, maxWeeklyRuns: {} };
35
+ try {
36
+ budgets = { ...budgets, ...JSON.parse(readFileSync(join(here, "budgets.json"), "utf8")) };
37
+ } catch {}
38
+ const budgetFor = (map, name, fallback) => map?.[name] ?? map?.["*"] ?? fallback;
39
+
40
+ const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
41
+ const iso = (hoursAgo) => new Date(Date.now() - hoursAgo * 3600_000).toISOString();
42
+ const runsSince = (sinceIso) =>
43
+ JSON.parse(
44
+ gh(["api", `repos/${repo}/actions/runs?created=>${sinceIso}&per_page=100`]),
45
+ ).workflow_runs.filter((r) => WATCHLIST.includes(r.name));
46
+
47
+ const day = runsSince(iso(24));
48
+ const week = runsSince(iso(24 * 7));
49
+
50
+ const problems = [];
51
+ const advisories = [];
52
+ const rows = [];
53
+ for (const name of WATCHLIST) {
54
+ const daily = day.filter((r) => r.name === name);
55
+ const failures = daily.filter((r) =>
56
+ ["failure", "startup_failure", "timed_out"].includes(r.conclusion ?? ""),
57
+ );
58
+ const weeklyCount = week.filter((r) => r.name === name).length;
59
+ const maxFail = budgetFor(budgets.maxDailyFailures, name, 3);
60
+ const maxWeekly = budgetFor(budgets.maxWeeklyRuns, name, Infinity);
61
+ const latest = daily[0] ?? week.filter((r) => r.name === name)[0];
62
+ const consecutiveFailures = (
63
+ daily.length ? daily : week.filter((r) => r.name === name)
64
+ ).findIndex((run) => !["failure", "startup_failure", "timed_out"].includes(run.conclusion ?? ""));
65
+ const failureStreak =
66
+ consecutiveFailures === -1
67
+ ? (daily.length ? daily : week.filter((r) => r.name === name)).length
68
+ : consecutiveFailures;
69
+ const classification =
70
+ weeklyCount === 0
71
+ ? "unknown"
72
+ : failures.length >= maxFail || weeklyCount > maxWeekly || failureStreak >= 2
73
+ ? "unhealthy"
74
+ : failures.length > 0 ||
75
+ ["failure", "startup_failure", "timed_out"].includes(latest?.conclusion ?? "")
76
+ ? "degraded"
77
+ : "healthy";
78
+ if (failures.length >= maxFail || failureStreak >= 2) {
79
+ problems.push(
80
+ `**${name}**: ${failures.length} failures in 24h (budget ${maxFail}), streak ${failureStreak} — latest: ${failures[0]?.html_url ?? latest?.html_url ?? "unknown"}`,
81
+ );
82
+ } else if (classification === "degraded") {
83
+ advisories.push(`**${name}**: a recent run failed, below the incident threshold.`);
84
+ }
85
+ if (weeklyCount > maxWeekly) {
86
+ problems.push(
87
+ `**${name}**: ${weeklyCount} runs this week (budget ${maxWeekly}) — runaway trigger or runaway spend?`,
88
+ );
89
+ }
90
+ rows.push(
91
+ `| ${name} | ${classification} | ${daily.length} | ${failures.length} | ${weeklyCount} |`,
92
+ );
93
+ }
94
+
95
+ const MARKER = "facility-health";
96
+ try {
97
+ gh([
98
+ "label",
99
+ "create",
100
+ MARKER,
101
+ "--force",
102
+ "--color",
103
+ "D1242F",
104
+ "--description",
105
+ "facility health incident",
106
+ ]);
107
+ } catch {}
108
+ const openIncidents = JSON.parse(
109
+ gh(["issue", "list", "--label", MARKER, "--state", "open", "--json", "number", "--limit", "1"]),
110
+ );
111
+
112
+ const report = [
113
+ `Health check ${new Date().toISOString()} — last 24h / 7d, from the GitHub API.`,
114
+ "",
115
+ "| workflow | health | runs 24h | failures 24h | runs 7d |",
116
+ "|---|---|---|---|---|",
117
+ ...rows,
118
+ "",
119
+ problems.length
120
+ ? `### Problems\n${problems.map((p) => `- ${p}`).join("\n")}`
121
+ : "All watched workflows within budget.",
122
+ advisories.length ? `### Degraded\n${advisories.map((p) => `- ${p}`).join("\n")}` : "",
123
+ ].join("\n");
124
+
125
+ if (problems.length) {
126
+ if (openIncidents.length === 0) {
127
+ gh([
128
+ "issue",
129
+ "create",
130
+ "--title",
131
+ "Facility health incident",
132
+ "--label",
133
+ MARKER,
134
+ "--body",
135
+ report,
136
+ ]);
137
+ } else {
138
+ gh(["issue", "comment", String(openIncidents[0].number), "--body", report]);
139
+ }
140
+ console.error(report);
141
+ process.exit(1); // red run = the at-a-glance signal
142
+ }
143
+
144
+ if (openIncidents.length > 0) {
145
+ gh(["issue", "comment", String(openIncidents[0].number), "--body", `Recovered.\n\n${report}`]);
146
+ gh(["issue", "close", String(openIncidents[0].number)]);
147
+ }
148
+ console.log(report);
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Generated by facility — https://github.com/theam/facility
3
+ //
4
+ // Nightly outcome collector. Receipts measure what a run consumed; outcomes
5
+ // measure whether the work was ACCEPTED. This joins every agent PR that
6
+ // reached a terminal state in the lookback window with acceptance evidence:
7
+ // human merger + enforced squash-only merge policy, linked issue lead time,
8
+ // review rounds, and human fixup commits. Missing evidence is reported as
9
+ // unassessed rather than guessed.
10
+ //
11
+ // Privacy: numbers, enums, and PR numbers only. Never titles, bodies, diffs.
12
+ // Env: GH_TOKEN (read-only), GITHUB_REPOSITORY, WINDOW_HOURS (26),
13
+ // PR_LIMIT (100), OUTPUT_DIR (optional artifact dir),
14
+ // WATCHTOWER_WEBHOOK_URL (optional JSON sink).
15
+ import { execFileSync } from "node:child_process";
16
+ import { mkdirSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const repo = process.env.GITHUB_REPOSITORY;
20
+ if (!repo) throw new Error("GITHUB_REPOSITORY is required");
21
+ const windowHours = Number(process.env.WINDOW_HOURS || 26);
22
+ const prLimit = Number(process.env.PR_LIMIT || 100);
23
+ const since = Date.now() - windowHours * 3600_000;
24
+
25
+ const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
26
+ const api = (path) => JSON.parse(gh(["api", path]));
27
+ const graphql = (query, fields) =>
28
+ JSON.parse(
29
+ gh([
30
+ "api",
31
+ "graphql",
32
+ "-f",
33
+ `query=${query}`,
34
+ ...Object.entries(fields).flatMap(([key, value]) => [
35
+ typeof value === "number" ? "-F" : "-f",
36
+ `${key}=${value}`,
37
+ ]),
38
+ ]),
39
+ ).data;
40
+ const isBot = (login) => typeof login === "string" && login.endsWith("[bot]");
41
+ const AGENT_BRANCH_PREFIXES = ["claude/", "codex/", "copilot/"];
42
+
43
+ const closed = api(
44
+ `repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=${Math.min(prLimit, 100)}`,
45
+ );
46
+ const terminal = closed.filter((pr) => Date.parse(pr.closed_at) >= since);
47
+ const [owner, name] = repo.split("/");
48
+
49
+ const EVIDENCE_QUERY = `query FacilityOutcomeEvidence($owner: String!, $name: String!, $number: Int!) {
50
+ repository(owner: $owner, name: $name) {
51
+ mergeCommitAllowed
52
+ rebaseMergeAllowed
53
+ squashMergeAllowed
54
+ pullRequest(number: $number) {
55
+ mergedBy { login __typename }
56
+ mergeCommit { parents(first: 2) { totalCount } }
57
+ closingIssuesReferences(
58
+ first: 1
59
+ orderBy: { field: CREATED_AT, direction: ASC }
60
+ ) { nodes { number createdAt } }
61
+ }
62
+ }
63
+ }`;
64
+
65
+ function acceptanceEvidence(pr) {
66
+ if (!pr.merged_at) return { accepted: false, mergeMethod: null, issue: null };
67
+ try {
68
+ const repository = graphql(EVIDENCE_QUERY, { owner, name, number: pr.number }).repository;
69
+ const parents = repository.pullRequest?.mergeCommit?.parents.totalCount ?? null;
70
+ let mergeMethod = "unverified";
71
+ if (parents >= 2) {
72
+ mergeMethod = "merge";
73
+ } else if (parents === 1) {
74
+ if (repository.squashMergeAllowed && !repository.rebaseMergeAllowed) mergeMethod = "squash";
75
+ if (repository.rebaseMergeAllowed && !repository.squashMergeAllowed) mergeMethod = "rebase";
76
+ } else {
77
+ const allowed = [
78
+ repository.mergeCommitAllowed ? "merge" : null,
79
+ repository.rebaseMergeAllowed ? "rebase" : null,
80
+ repository.squashMergeAllowed ? "squash" : null,
81
+ ].filter(Boolean);
82
+ mergeMethod = allowed.length === 1 ? allowed[0] : "unverified";
83
+ }
84
+ const mergedBy = repository.pullRequest?.mergedBy ?? null;
85
+ const accepted =
86
+ mergeMethod === "unverified" || !mergedBy
87
+ ? null
88
+ : mergeMethod === "squash" && mergedBy.__typename === "User";
89
+ const issue = repository.pullRequest?.closingIssuesReferences.nodes?.[0] ?? null;
90
+ return { accepted, mergeMethod, mergedBy, issue };
91
+ } catch {
92
+ return {
93
+ accepted: null,
94
+ mergeMethod: "unverified",
95
+ mergedBy: null,
96
+ issue: null,
97
+ evidenceError: true,
98
+ };
99
+ }
100
+ }
101
+
102
+ function isAgentPr(pr, commits) {
103
+ if (isBot(pr.user?.login)) return true;
104
+ if (AGENT_BRANCH_PREFIXES.some((p) => pr.head?.ref?.startsWith(p))) return true;
105
+ // A bot-built branch where a human opened the PR: first commit is agent-authored.
106
+ return commits.length > 0 && isBot(commits[0]?.author?.login);
107
+ }
108
+
109
+ const outcomes = [];
110
+ for (const pr of terminal) {
111
+ const commits = api(`repos/${repo}/pulls/${pr.number}/commits?per_page=100`);
112
+ if (!isAgentPr(pr, commits)) continue;
113
+ const reviews = api(`repos/${repo}/pulls/${pr.number}/reviews?per_page=100`);
114
+ const firstBotIndex = commits.findIndex((c) => isBot(c.author?.login));
115
+ const humanFixups =
116
+ firstBotIndex === -1
117
+ ? 0
118
+ : commits.slice(firstBotIndex + 1).filter((c) => c.author?.login && !isBot(c.author.login))
119
+ .length;
120
+ const reviewRounds = reviews.filter((r) => r.state === "CHANGES_REQUESTED").length;
121
+ const merged = Boolean(pr.merged_at);
122
+ const evidence = acceptanceEvidence(pr);
123
+ outcomes.push({
124
+ pr: pr.number,
125
+ lane: pr.head?.ref?.split("/")[0] ?? "unknown",
126
+ merged,
127
+ accepted: evidence.accepted,
128
+ assessed: evidence.accepted !== null,
129
+ mergeMethod: evidence.mergeMethod,
130
+ mergedBy: evidence.mergedBy?.login ?? null,
131
+ mergerType: evidence.mergedBy?.__typename ?? null,
132
+ evidenceError: evidence.evidenceError ?? null,
133
+ issue: evidence.issue?.number ?? null,
134
+ hoursIssueToMerge:
135
+ evidence.issue &&
136
+ pr.merged_at &&
137
+ Date.parse(pr.merged_at) >= Date.parse(evidence.issue.createdAt)
138
+ ? Number(
139
+ ((Date.parse(pr.merged_at) - Date.parse(evidence.issue.createdAt)) / 3600_000).toFixed(
140
+ 2,
141
+ ),
142
+ )
143
+ : null,
144
+ hoursToTerminal: Math.round((Date.parse(pr.closed_at) - Date.parse(pr.created_at)) / 3600_000),
145
+ reviewRounds,
146
+ humanFixups,
147
+ oneShot: Boolean(pr.merged_at) && reviewRounds === 0 && humanFixups === 0,
148
+ });
149
+ }
150
+
151
+ const mergedCount = outcomes.filter((o) => o.merged).length;
152
+ const assessedCount = outcomes.filter((o) => o.assessed).length;
153
+ const acceptedCount = outcomes.filter((o) => o.accepted).length;
154
+ const summary = {
155
+ schema: "facility.watchtower.outcomes.v2",
156
+ collectedAt: new Date().toISOString(),
157
+ windowHours,
158
+ agentPrs: outcomes.length,
159
+ merged: mergedCount,
160
+ rejected: outcomes.length - mergedCount,
161
+ assessed: assessedCount,
162
+ accepted: acceptedCount,
163
+ notAccepted: assessedCount - acceptedCount,
164
+ unassessed: outcomes.length - assessedCount,
165
+ acceptance: assessedCount ? Math.round((100 * acceptedCount) / assessedCount) : null,
166
+ oneShot: outcomes.filter((o) => o.oneShot).length,
167
+ outcomes,
168
+ };
169
+
170
+ if (process.env.OUTPUT_DIR) {
171
+ mkdirSync(process.env.OUTPUT_DIR, { recursive: true });
172
+ writeFileSync(join(process.env.OUTPUT_DIR, "outcomes.json"), JSON.stringify(summary, null, 2));
173
+ }
174
+ if (process.env.WATCHTOWER_WEBHOOK_URL) {
175
+ await fetch(process.env.WATCHTOWER_WEBHOOK_URL, {
176
+ method: "POST",
177
+ headers: { "content-type": "application/json" },
178
+ body: JSON.stringify(summary),
179
+ }).catch((error) => console.error(`webhook sink failed (non-fatal): ${error.message}`));
180
+ }
181
+
182
+ console.log(
183
+ JSON.stringify({
184
+ agentPrs: summary.agentPrs,
185
+ acceptance: summary.acceptance,
186
+ oneShot: summary.oneShot,
187
+ }),
188
+ );