@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,157 @@
1
+ #!/usr/bin/env node
2
+ // Generated by facility — deterministic /builder delivery acceptance.
3
+ import { execFileSync } from "node:child_process";
4
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const mode = process.argv[2];
8
+ const repo = required("GITHUB_REPOSITORY");
9
+ const defaultBranch = required("DEFAULT_BRANCH");
10
+ const receiptDir = join(required("RUNNER_TEMP"), "facility-delivery");
11
+ const receiptPath = join(receiptDir, "receipt.json");
12
+ const conventionalSubject =
13
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\((?=\S)[^()\r\n]*[^\s()\r\n]\))?!?: \S.*$/;
14
+
15
+ if (mode === "discover") discover();
16
+ else if (mode === "finalize") finalize();
17
+ else throw new Error("Usage: verify.mjs discover|finalize");
18
+
19
+ function discover() {
20
+ const event = JSON.parse(readFileSync(required("GITHUB_EVENT_PATH"), "utf8"));
21
+ const startedAt = required("FACILITY_STARTED_AT");
22
+ const startSha = required("FACILITY_START_SHA");
23
+ const existingPr =
24
+ event.pull_request?.number ?? (event.issue?.pull_request ? event.issue.number : undefined);
25
+ const pr = existingPr ? pull(existingPr) : newlyOpenedPull(startedAt);
26
+
27
+ assert(pr.state === "open", `PR #${pr.number} is not open`);
28
+ assert(!pr.draft, `PR #${pr.number} is still a draft; automated review would not run`);
29
+ assert(
30
+ pr.base?.ref === defaultBranch,
31
+ `PR #${pr.number} targets ${pr.base?.ref}, not ${defaultBranch}`,
32
+ );
33
+ assert(pr.head?.repo?.full_name === repo, `PR #${pr.number} is not a same-repository PR`);
34
+ assert(
35
+ pr.user?.type === "Bot",
36
+ `PR #${pr.number} is not bot-authored; address-review would be disabled`,
37
+ );
38
+ assert(
39
+ /^(feature|fix|chore|ci|docs|refactor|perf|test|build|revert)\/[a-z0-9][a-z0-9._/-]*$/.test(
40
+ pr.head.ref,
41
+ ),
42
+ `branch ${pr.head?.ref} is not semantic or still carries an agent/tool prefix`,
43
+ );
44
+ assert(pr.head?.sha !== startSha, "builder reported success without delivering a new commit");
45
+
46
+ const commits = ghJson(["api", `repos/${repo}/pulls/${pr.number}/commits?per_page=100`]);
47
+ const startIndex = commits.findIndex((commit) => commit.sha === startSha);
48
+ if (existingPr) {
49
+ assert(
50
+ startIndex >= 0,
51
+ `PR #${pr.number} no longer contains its pre-builder head; history was rewritten`,
52
+ );
53
+ }
54
+ const delivered = startIndex >= 0 ? commits.slice(startIndex + 1) : commits;
55
+ assert(delivered.length > 0, `PR #${pr.number} contains no builder-delivered commits`);
56
+
57
+ for (const commit of delivered) {
58
+ const message = commit.commit?.message ?? "";
59
+ const subject = message.split(/\r?\n/, 1)[0];
60
+ assert(
61
+ !hasForbiddenSubjectCharacters(subject) && conventionalSubject.test(subject),
62
+ `commit ${commit.sha} is not Conventional Commits: ${subject}`,
63
+ );
64
+ assert(
65
+ !/^Co-authored-by:/im.test(message),
66
+ `commit ${commit.sha} contains a Co-authored-by trailer`,
67
+ );
68
+ assert(
69
+ commit.commit?.verification?.verified === true,
70
+ `commit ${commit.sha} is not verified by GitHub`,
71
+ );
72
+ }
73
+
74
+ mkdirSync(receiptDir, { recursive: true });
75
+ const receipt = {
76
+ schema: "facility.delivery.v1",
77
+ repository: repo,
78
+ pullRequest: { number: pr.number, url: pr.html_url, base: pr.base.ref },
79
+ branch: pr.head.ref,
80
+ headSha: pr.head.sha,
81
+ author: pr.user.login,
82
+ deliveredCommits: delivered.map((commit) => commit.sha),
83
+ startedAt,
84
+ verification: "pending",
85
+ };
86
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
87
+ output("pr_number", String(pr.number));
88
+ output("head_ref", pr.head.ref);
89
+ output("head_sha", pr.head.sha);
90
+ console.log(`Validated delivery metadata for ${pr.html_url} at ${pr.head.sha}.`);
91
+ }
92
+
93
+ function finalize() {
94
+ const prNumber = required("FACILITY_PR_NUMBER");
95
+ const headRef = required("FACILITY_HEAD_REF");
96
+ const headSha = required("FACILITY_HEAD_SHA");
97
+ const pr = pull(prNumber);
98
+ assert(pr.head?.ref === headRef, `PR #${prNumber} head branch changed during verification`);
99
+ assert(pr.head?.sha === headSha, `PR #${prNumber} head commit changed during verification`);
100
+
101
+ const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
102
+ receipt.verification = "passed";
103
+ receipt.verifiedAt = new Date().toISOString();
104
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
105
+ console.log(JSON.stringify(receipt));
106
+ }
107
+
108
+ function newlyOpenedPull(startedAt) {
109
+ const pulls = ghJson([
110
+ "api",
111
+ `repos/${repo}/pulls?state=open&base=${encodeURIComponent(defaultBranch)}&sort=created&direction=desc&per_page=50`,
112
+ ]).filter(
113
+ (pr) =>
114
+ pr.head?.repo?.full_name === repo &&
115
+ pr.created_at >= startedAt &&
116
+ pr.head?.sha !== process.env.FACILITY_START_SHA,
117
+ );
118
+ assert(
119
+ pulls.length === 1,
120
+ `expected exactly one new PR from this builder run; found ${pulls.length}`,
121
+ );
122
+ return pulls[0];
123
+ }
124
+
125
+ function pull(number) {
126
+ return ghJson(["api", `repos/${repo}/pulls/${number}`]);
127
+ }
128
+
129
+ function ghJson(args) {
130
+ return JSON.parse(execFileSync("gh", args, { encoding: "utf8" }));
131
+ }
132
+
133
+ function output(name, value) {
134
+ appendFileSync(required("GITHUB_OUTPUT"), `${name}=${value}\n`);
135
+ }
136
+
137
+ function hasForbiddenSubjectCharacters(subject) {
138
+ return [...subject].some((character) => {
139
+ const codePoint = character.codePointAt(0) ?? 0;
140
+ return (
141
+ codePoint <= 0x1f ||
142
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
143
+ codePoint === 0x2028 ||
144
+ codePoint === 0x2029
145
+ );
146
+ });
147
+ }
148
+
149
+ function required(name) {
150
+ const value = process.env[name];
151
+ if (!value) throw new Error(`${name} is required`);
152
+ return value;
153
+ }
154
+
155
+ function assert(condition, message) {
156
+ if (!condition) throw new Error(message);
157
+ }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ // Generated by facility — https://github.com/theam/facility
4
+ //
5
+ // Deterministic doctor resolver. Runs on every completed watched workflow and
6
+ // decides — with rules, not judgment — what the doctor may do:
7
+ //
8
+ // none not a failure / not a PR / already handled (fingerprint dedup)
9
+ // triage post one concise comment pointing a human at the failure
10
+ // repair start the bounded repair agent (crew-authored PRs only, and only
11
+ // when neither the failure nor the PR touches a sensitive surface)
12
+ //
13
+ // The repair policy is deliberately conservative: human branches get triage,
14
+ // never uninvited commits. Sensitive surfaces are always triage-only.
15
+ // Env: GH_TOKEN, GITHUB_REPOSITORY, GITHUB_EVENT_PATH, GITHUB_OUTPUT.
16
+ import { createHash } from "node:crypto";
17
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+
19
+ const SENSITIVE_PATHS = [
20
+ ".github/workflows/",
21
+ ".github/facility/",
22
+ "guards/",
23
+ ".claude/",
24
+ ".env",
25
+ "migrations/",
26
+ "supabase/migrations/",
27
+ "db/migrations/",
28
+ "prisma/migrations/",
29
+ "package-lock.json",
30
+ "pnpm-lock.yaml",
31
+ "yarn.lock",
32
+ ];
33
+
34
+ const repo = process.env.GITHUB_REPOSITORY;
35
+ const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
36
+ const run = event.workflow_run;
37
+ const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
38
+ const out = (key, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`);
39
+
40
+ function decide() {
41
+ if (run?.conclusion !== "failure") return { action: "none", reason: "watched run did not fail" };
42
+ const prNumber = run.pull_requests?.[0]?.number;
43
+ if (!prNumber) return { action: "none", reason: "no same-repo PR attached to the failed run" };
44
+
45
+ const pr = JSON.parse(gh(["api", `repos/${repo}/pulls/${prNumber}`]));
46
+ if (pr.state !== "open" || pr.draft) return { action: "none", reason: "PR closed or draft" };
47
+
48
+ const fingerprint = createHash("sha256")
49
+ .update(`${run.name}|${run.head_branch}`)
50
+ .digest("hex")
51
+ .slice(0, 16);
52
+ const marker = `<!-- facility-doctor:${fingerprint} -->`;
53
+ const comments = JSON.parse(
54
+ gh(["api", `repos/${repo}/issues/${prNumber}/comments?per_page=100`]),
55
+ );
56
+ if (comments.some((c) => c.body?.includes(marker))) {
57
+ return { action: "none", reason: "this failure fingerprint was already handled on the PR" };
58
+ }
59
+
60
+ const failedJobs = JSON.parse(
61
+ gh(["api", `repos/${repo}/actions/runs/${run.id}/jobs?per_page=50`]),
62
+ )
63
+ .jobs.filter((j) => j.conclusion === "failure")
64
+ .map((j) => ({
65
+ name: j.name,
66
+ steps: (j.steps ?? []).filter((s) => s.conclusion === "failure").map((s) => s.name),
67
+ }));
68
+
69
+ const files = gh([
70
+ "api",
71
+ `repos/${repo}/pulls/${prNumber}/files?per_page=100`,
72
+ "--jq",
73
+ ".[].filename",
74
+ ])
75
+ .split("\n")
76
+ .filter(Boolean);
77
+ const sensitive = files.filter((f) =>
78
+ SENSITIVE_PATHS.some((p) => f === p || f.startsWith(p) || f.includes(`/${p}`)),
79
+ );
80
+ const authorIsBot = (pr.user?.login ?? "").endsWith("[bot]");
81
+
82
+ const base = { prNumber, pr, fingerprint, marker, failedJobs, sensitive };
83
+ if (sensitive.length)
84
+ return {
85
+ ...base,
86
+ action: "triage",
87
+ reason: `PR touches sensitive surfaces (${sensitive.slice(0, 5).join(", ")})`,
88
+ };
89
+ if (!authorIsBot)
90
+ return {
91
+ ...base,
92
+ action: "triage",
93
+ reason: "human-authored PR — the doctor never commits to your branch uninvited",
94
+ };
95
+ return { ...base, action: "repair", reason: "crew-authored PR, non-sensitive failure" };
96
+ }
97
+
98
+ const decision = decide();
99
+ console.log(`doctor: ${decision.action} — ${decision.reason}`);
100
+ out("action", decision.action);
101
+ if (decision.action === "none") process.exit(0);
102
+
103
+ const jobsText = decision.failedJobs
104
+ .map((j) => `- **${j.name}**${j.steps.length ? ` → ${j.steps.join(", ")}` : ""}`)
105
+ .join("\n");
106
+
107
+ if (decision.action === "triage") {
108
+ const body = [
109
+ `**Doctor triage** — \`${run.name}\` failed on this PR ([run](${run.html_url})).`,
110
+ "",
111
+ jobsText,
112
+ "",
113
+ `Not auto-repaired: ${decision.reason}.`,
114
+ decision.marker,
115
+ ].join("\n");
116
+ gh(["api", `repos/${repo}/issues/${decision.prNumber}/comments`, "-f", `body=${body}`]);
117
+ process.exit(0);
118
+ }
119
+
120
+ // repair: hand the agent a sanitized task packet (names and refs only — no
121
+ // raw logs, which can carry secrets; the agent reproduces on the provisioned
122
+ // runner instead).
123
+ mkdirSync(".facility-doctor", { recursive: true });
124
+ writeFileSync(
125
+ ".facility-doctor/context.json",
126
+ JSON.stringify(
127
+ {
128
+ schema: "facility.doctor.context.v1",
129
+ failure: { workflow: run.name, runUrl: run.html_url, failedJobs: decision.failedJobs },
130
+ pr: {
131
+ number: decision.prNumber,
132
+ headRef: decision.pr.head.ref,
133
+ baseRef: decision.pr.base.ref,
134
+ },
135
+ fingerprint: decision.fingerprint,
136
+ marker: decision.marker,
137
+ },
138
+ null,
139
+ 2,
140
+ ),
141
+ );
142
+ out("pr_number", String(decision.prNumber));
143
+ out("head_ref", decision.pr.head.ref);
144
+ out("fingerprint", decision.fingerprint);
@@ -0,0 +1,30 @@
1
+ # Guards
2
+
3
+ Deterministic, fast invariant checks for this repository, run as **one**
4
+ umbrella status. Generated by [facility](https://github.com/theam/facility);
5
+ every file here is yours.
6
+
7
+ ```
8
+ node guards/run.mjs # run all guards
9
+ node guards/run.mjs --only=<name> # run one guard (repeatable)
10
+ node guards/run.mjs --json # machine-readable (CI / agents)
11
+ node guards/run.mjs --list # list registered guards
12
+ ```
13
+
14
+ The rule behind this directory, from `STANDARD.md`: **if a rule is repeatedly
15
+ missed, add a deterministic check instead of more prose.** Prose decays;
16
+ checks hold.
17
+
18
+ ## Add a guard
19
+
20
+ 1. Create `guards/<name>.mjs` exporting a default `{ name, description,
21
+ requires?, run() }`. Return a list of `{ file?, line?, message }`
22
+ violations (empty = pass). Keep it deterministic and read-only.
23
+ 2. Give it an in-code `ALLOWLIST` (keyed, with a written reason per entry) for
24
+ justified exceptions — `applyAllowlist` from `_kit.mjs` flags stale entries
25
+ so the list cannot rot.
26
+ 3. To wrap an existing CLI/DB check instead of porting its logic, use
27
+ `commandGuard(...)` from `_kit.mjs`.
28
+ 4. Guards needing external state declare `requires: ["SOME_ENV_VAR"]` and are
29
+ **skipped** (not failed) when it is absent, so local runs stay fast while
30
+ CI runs everything.
@@ -0,0 +1,81 @@
1
+ // Generated by facility — https://github.com/theam/facility
2
+ //
3
+ // Small helpers for writing guards. Zero dependencies; everything is yours
4
+ // to read and change.
5
+ import { execFileSync } from "node:child_process";
6
+ import { readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ /** Recursively list files under `dir` matching any of `extensions`. */
10
+ export function listFiles(dir, extensions, ignore = ["node_modules", ".git"]) {
11
+ const out = [];
12
+ let entries;
13
+ try {
14
+ entries = readdirSync(dir);
15
+ } catch {
16
+ return out;
17
+ }
18
+ for (const entry of entries) {
19
+ if (ignore.includes(entry)) continue;
20
+ const path = join(dir, entry);
21
+ const stats = statSync(path);
22
+ if (stats.isDirectory()) out.push(...listFiles(path, extensions, ignore));
23
+ else if (extensions.some((ext) => entry.endsWith(ext))) out.push(path);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** Read a file as UTF-8, returning "" when it does not exist. */
29
+ export function readText(path) {
30
+ try {
31
+ return readFileSync(path, "utf8");
32
+ } catch {
33
+ return "";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Wrap an existing CLI/DB check as a guard instead of re-implementing its
39
+ * logic. The command's non-zero exit becomes a single violation carrying its
40
+ * output tail.
41
+ */
42
+ export function commandGuard({ name, description, command, args = [], requires = [] }) {
43
+ return {
44
+ name,
45
+ description,
46
+ requires,
47
+ run() {
48
+ try {
49
+ execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
50
+ return [];
51
+ } catch (error) {
52
+ const tail = `${error.stdout ?? ""}${error.stderr ?? ""}`
53
+ .trim()
54
+ .split("\n")
55
+ .slice(-12)
56
+ .join("\n");
57
+ return [{ message: `\`${[command, ...args].join(" ")}\` failed:\n${tail}` }];
58
+ }
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Filter violations through a keyed allowlist. Every allowlist entry must
65
+ * carry a written reason; entries that no longer match anything are reported
66
+ * as stale so the list cannot rot.
67
+ */
68
+ export function applyAllowlist(violations, allowlist) {
69
+ const used = new Set();
70
+ const kept = violations.filter((violation) => {
71
+ const hit = Object.keys(allowlist).find((key) => violation.key === key);
72
+ if (hit) used.add(hit);
73
+ return !hit;
74
+ });
75
+ for (const key of Object.keys(allowlist)) {
76
+ if (!used.has(key)) {
77
+ kept.push({ message: `stale allowlist entry "${key}" (${allowlist[key]}) — remove it` });
78
+ }
79
+ }
80
+ return kept;
81
+ }
@@ -0,0 +1,38 @@
1
+ // Generated by facility — https://github.com/theam/facility
2
+ //
3
+ // Every third-party GitHub Action must be pinned to a full commit SHA.
4
+ // Tags and branches are mutable: a compromised action repo can re-point
5
+ // `v4` at malicious code and your CI — which holds secrets and write
6
+ // tokens — runs it on the next push. A 40-hex SHA cannot be re-pointed.
7
+ import { applyAllowlist, listFiles, readText } from "./_kit.mjs";
8
+
9
+ // key: "<file>:<uses-ref>", value: written reason. Keep this empty unless a
10
+ // pin is truly impossible; document why.
11
+ const ALLOWLIST = {};
12
+
13
+ export default {
14
+ name: "actions-pinned",
15
+ description: "third-party GitHub Actions are pinned to full commit SHAs",
16
+ run() {
17
+ const violations = [];
18
+ for (const file of listFiles(".github/workflows", [".yml", ".yaml"])) {
19
+ const lines = readText(file).split("\n");
20
+ lines.forEach((line, index) => {
21
+ const match = line.match(/^\s*(?:-\s+)?uses:\s*([^\s#]+)/);
22
+ if (!match) return;
23
+ const ref = match[1].replace(/^["']|["']$/g, "");
24
+ if (ref.startsWith("./") || ref.startsWith("docker://")) return;
25
+ const pinned = /@[0-9a-f]{40}$/.test(ref);
26
+ if (!pinned) {
27
+ violations.push({
28
+ file,
29
+ line: index + 1,
30
+ key: `${file}:${ref}`,
31
+ message: `"${ref}" is not pinned to a full commit SHA (use @<40-hex-sha> with a version comment)`,
32
+ });
33
+ }
34
+ });
35
+ }
36
+ return applyAllowlist(violations, ALLOWLIST);
37
+ },
38
+ };
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // Generated by facility — https://github.com/theam/facility
3
+ //
4
+ // Deterministic, fast invariant checks that run as ONE umbrella status
5
+ // instead of a sprawl of bespoke CI steps. Standard toolchain checks
6
+ // (typecheck, lint, tests, build) stay as their own statuses — guards are
7
+ // only for repo-specific invariants.
8
+ //
9
+ // node guards/run.mjs run all guards
10
+ // node guards/run.mjs --only=<name> run one guard (repeatable)
11
+ // node guards/run.mjs --json machine-readable (CI / agents)
12
+ // node guards/run.mjs --list list registered guards
13
+ //
14
+ // A guard is a file in this directory exporting:
15
+ //
16
+ // export default {
17
+ // name: "my-invariant",
18
+ // description: "what it enforces, in one line",
19
+ // requires: ["SOME_ENV_VAR"], // optional: skip (not fail) when absent
20
+ // async run() { return [/* violations */]; },
21
+ // }
22
+ //
23
+ // A violation: { file?, line?, message }. Empty array = pass.
24
+ // Keep guards deterministic and read-only. Justified exceptions belong in an
25
+ // in-code ALLOWLIST inside the guard, keyed, with a written reason per entry.
26
+ //
27
+ // Design goal: adding a new safety invariant is a single small file — easy
28
+ // for engineers and for agents to extend. If a rule in STANDARD.md is
29
+ // repeatedly missed, it belongs here.
30
+ import { readdirSync } from "node:fs";
31
+ import { basename, dirname, join } from "node:path";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const args = process.argv.slice(2);
36
+ const asJson = args.includes("--json");
37
+ const list = args.includes("--list");
38
+ const only = args.filter((a) => a.startsWith("--only=")).map((a) => a.slice(7));
39
+
40
+ const guardFiles = readdirSync(here)
41
+ .filter(
42
+ (f) =>
43
+ f.endsWith(".mjs") && !f.startsWith("_") && f !== basename(fileURLToPath(import.meta.url)),
44
+ )
45
+ .sort();
46
+
47
+ const guards = [];
48
+ for (const file of guardFiles) {
49
+ const mod = await import(pathToFileURL(join(here, file)).href);
50
+ const guard = mod.default;
51
+ if (!guard?.name || typeof guard.run !== "function") {
52
+ console.error(`guards/${file} does not export a valid guard ({ name, description, run }).`);
53
+ process.exit(1);
54
+ }
55
+ guards.push({ ...guard, file });
56
+ }
57
+
58
+ if (list) {
59
+ for (const g of guards) console.log(`${g.name.padEnd(32)} ${g.description ?? ""}`);
60
+ process.exit(0);
61
+ }
62
+
63
+ const selected = only.length ? guards.filter((g) => only.includes(g.name)) : guards;
64
+ if (only.length && selected.length !== only.length) {
65
+ const known = new Set(guards.map((g) => g.name));
66
+ for (const name of only.filter((n) => !known.has(n))) console.error(`Unknown guard: ${name}`);
67
+ process.exit(1);
68
+ }
69
+
70
+ const results = [];
71
+ for (const guard of selected) {
72
+ const missing = (guard.requires ?? []).filter((envVar) => !process.env[envVar]);
73
+ if (missing.length) {
74
+ results.push({
75
+ name: guard.name,
76
+ status: "skipped",
77
+ reason: `missing env: ${missing.join(", ")}`,
78
+ violations: [],
79
+ });
80
+ continue;
81
+ }
82
+ try {
83
+ const violations = (await guard.run()) ?? [];
84
+ results.push({ name: guard.name, status: violations.length ? "fail" : "pass", violations });
85
+ } catch (error) {
86
+ results.push({
87
+ name: guard.name,
88
+ status: "fail",
89
+ violations: [{ message: `guard crashed: ${error?.message ?? error}` }],
90
+ });
91
+ }
92
+ }
93
+
94
+ const failed = results.filter((r) => r.status === "fail");
95
+
96
+ if (asJson) {
97
+ console.log(JSON.stringify({ ok: failed.length === 0, results }, null, 2));
98
+ } else {
99
+ for (const r of results) {
100
+ const mark = r.status === "pass" ? "✓" : r.status === "skipped" ? "—" : "✗";
101
+ console.log(`${mark} ${r.name}${r.status === "skipped" ? ` (skipped: ${r.reason})` : ""}`);
102
+ for (const v of r.violations) {
103
+ const where = v.file ? ` ${v.file}${v.line ? `:${v.line}` : ""} ` : " ";
104
+ console.log(` ${where}${v.message}`);
105
+ }
106
+ }
107
+ const ran = results.filter((r) => r.status !== "skipped").length;
108
+ console.log(`\n${ran} guard${ran === 1 ? "" : "s"} ran, ${failed.length} failed.`);
109
+ }
110
+
111
+ process.exit(failed.length ? 1 : 0);
@@ -0,0 +1,66 @@
1
+ // Generated by facility — https://github.com/theam/facility
2
+ //
3
+ // Structural drift in the watchtower must be visible. This guard requires the
4
+ // three workflow files and their cron entries, and verifies that the canary
5
+ // hash pinned in facility-crew.yml matches the canonical probe body in
6
+ // .github/facility/watchtower/canary.mjs. It does not evaluate job conditions
7
+ // or GitHub repository settings, so it cannot tell whether Actions or a
8
+ // schedule is enabled or runnable.
9
+ import { createHash } from "node:crypto";
10
+ import { pathToFileURL } from "node:url";
11
+ import { readText } from "./_kit.mjs";
12
+
13
+ export default {
14
+ name: "watchtower-locked",
15
+ description: "watchtower/canary cron entries exist and the canary hash stays in sync",
16
+ async run() {
17
+ const violations = [];
18
+
19
+ const scheduled = [
20
+ [".github/workflows/facility-watchtower.yml", 2],
21
+ [".github/workflows/facility-canary.yml", 1],
22
+ [".github/workflows/facility-security-sweep.yml", 1],
23
+ ];
24
+ for (const [file, minCrons] of scheduled) {
25
+ const text = readText(file);
26
+ if (!text) {
27
+ violations.push({ file, message: "missing required watchtower workflow" });
28
+ continue;
29
+ }
30
+ const crons = (text.match(/- cron:/g) ?? []).length;
31
+ if (crons < minCrons) {
32
+ violations.push({
33
+ file,
34
+ message: `expected at least ${minCrons} cron schedule(s), found ${crons}`,
35
+ });
36
+ }
37
+ }
38
+
39
+ const crew = readText(".github/workflows/facility-crew.yml");
40
+ if (!crew) {
41
+ violations.push({ file: ".github/workflows/facility-crew.yml", message: "missing" });
42
+ return violations;
43
+ }
44
+ try {
45
+ const { CANARY_PROBE_BODY } = await import(
46
+ pathToFileURL(".github/facility/watchtower/canary.mjs").href
47
+ );
48
+ const expected = createHash("sha256")
49
+ .update(CANARY_PROBE_BODY.replace(/\r/g, ""), "utf8")
50
+ .digest("hex");
51
+ if (!crew.includes(expected)) {
52
+ violations.push({
53
+ file: ".github/workflows/facility-crew.yml",
54
+ message: `canary hash out of sync — expected sha256 ${expected} (from canary.mjs CANARY_PROBE_BODY)`,
55
+ });
56
+ }
57
+ } catch {
58
+ violations.push({
59
+ file: ".github/facility/watchtower/canary.mjs",
60
+ message: "missing or unreadable — cannot verify the pinned canary hash",
61
+ });
62
+ }
63
+
64
+ return violations;
65
+ },
66
+ };
@@ -0,0 +1,14 @@
1
+ # Review feedback agent operating contract
2
+
3
+ Address the actionable review findings on the linked agent pull request. Read
4
+ the accepted plan, complete review thread, current diff, repository standard,
5
+ and failing checks before editing. Preserve contributor intent and do not make
6
+ unrelated changes.
7
+
8
+ For each thread, either implement the correction and prove it with the relevant
9
+ checks, or reply with concrete evidence that the request is already satisfied
10
+ or unsafe. When Facility owns execution, provide the existing PR branch and a
11
+ Conventional Commit message in the runner-requested delivery manifest; Facility
12
+ adds the signed commit to that exact branch. Never create another branch or pull
13
+ request, approve or merge, force-push, weaken a guard, or modify the protected
14
+ branch.