@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.
- package/LICENSE +201 -0
- package/README.md +68 -0
- package/bin/facility.mjs +10 -0
- package/modules/README.md +35 -0
- package/modules/ai-queryability/agents/queryability-reviewer.md +35 -0
- package/modules/ai-queryability/module.json +9 -0
- package/modules/ai-queryability/standard-section.md +22 -0
- package/modules/analytics/agents/analytics-reviewer.md +32 -0
- package/modules/analytics/commands/add-telemetry.md +23 -0
- package/modules/analytics/module.json +10 -0
- package/modules/analytics/standard-section.md +23 -0
- package/modules/database/agents/data-security-reviewer.md +38 -0
- package/modules/database/commands/new-migration.md +24 -0
- package/modules/database/guards/migration-versions.mjs +41 -0
- package/modules/database/guards/migrations-immutable.mjs +57 -0
- package/modules/database/hooks/protect-migrations.fragment.mjs +10 -0
- package/modules/database/module.json +25 -0
- package/modules/database/standard-section.md +20 -0
- package/modules/design-system/agents/design-reviewer.md +37 -0
- package/modules/design-system/module.json +9 -0
- package/modules/design-system/standard-section.md +15 -0
- package/package.json +42 -0
- package/src/add.mjs +77 -0
- package/src/cli.mjs +352 -0
- package/src/detect.mjs +127 -0
- package/src/doctor.mjs +582 -0
- package/src/init.mjs +572 -0
- package/src/instance.mjs +114 -0
- package/src/platform-admin.mjs +1542 -0
- package/src/platform-config.mjs +39 -0
- package/src/platform.mjs +1759 -0
- package/src/prompts.mjs +64 -0
- package/src/render.mjs +66 -0
- package/src/ui.mjs +30 -0
- package/templates/claude/agents/security-reviewer.md +41 -0
- package/templates/claude/agents/standards-reviewer.md +31 -0
- package/templates/claude/commands/open-pr.md +21 -0
- package/templates/claude/commands/verify.md +16 -0
- package/templates/claude/hooks/protect-branch.mjs +58 -0
- package/templates/claude/hooks/protect-files.mjs +35 -0
- package/templates/claude/settings.json +71 -0
- package/templates/claude/skills/maintainable-software/SKILL.md +67 -0
- package/templates/claude/skills/reviewing-to-standard/SKILL.md +49 -0
- package/templates/claude/skills/working-to-standard/SKILL.md +45 -0
- package/templates/delivery/verify.mjs +157 -0
- package/templates/doctor/resolve.mjs +144 -0
- package/templates/guards/README.md +30 -0
- package/templates/guards/_kit.mjs +81 -0
- package/templates/guards/actions-pinned.mjs +38 -0
- package/templates/guards/run.mjs +111 -0
- package/templates/guards/watchtower-locked.mjs +66 -0
- package/templates/prompts/address-review.md +14 -0
- package/templates/prompts/architect.md +62 -0
- package/templates/prompts/builder.md +71 -0
- package/templates/prompts/doctor.md +64 -0
- package/templates/prompts/review.md +14 -0
- package/templates/prompts/sweep.md +75 -0
- package/templates/receipts/collect.mjs +289 -0
- package/templates/review/finalize.mjs +38 -0
- package/templates/scripts/move-board-status.sh +155 -0
- package/templates/security/sync-findings.mjs +226 -0
- package/templates/standard/STANDARD.md +141 -0
- package/templates/standard/agents-block.md +25 -0
- package/templates/watchtower/budgets.json +12 -0
- package/templates/watchtower/canary.mjs +216 -0
- package/templates/watchtower/health.mjs +148 -0
- package/templates/watchtower/outcomes.mjs +188 -0
- package/templates/workflows/facility-address-review.yml +153 -0
- package/templates/workflows/facility-canary.yml +61 -0
- package/templates/workflows/facility-codex.yml +326 -0
- package/templates/workflows/facility-crew.yml +350 -0
- package/templates/workflows/facility-doctor.yml +155 -0
- package/templates/workflows/facility-review.yml +134 -0
- package/templates/workflows/facility-security-sweep.yml +204 -0
- 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.
|