madeby 0.3.12 → 0.3.14

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 (3) hide show
  1. package/README.md +42 -12
  2. package/dist/madeby.mjs +390 -69
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -67,22 +67,30 @@ sides of it, and `init`/`check` are the setup and the gate.
67
67
  ### `madeby check` — the disclosure gate
68
68
 
69
69
  Reads `.madeby/policy.json`, recognizes each commit's disclosure signals, and reports coverage.
70
- A commit **discloses its origin** if it carries any of:
70
+ A commit **discloses who wrote it** if it carries any of:
71
71
 
72
72
  - an **AI-authorship trailer** — `Co-Authored-By:` / `Generated-by:` / `Assisted-by:` naming an AI
73
- tool (Claude Code, Copilot, Cursor, aider, … already emit these);
73
+ tool (Claude Code, Copilot, Cursor, aider, … already emit these), or `Authored-by-ai:`;
74
74
  - an **affirmative human-authorship trailer** — `Authored-by-human: <you>` (see below);
75
- - a **DCO `Signed-off-by:`** line;
76
- - a **commit signature**;
77
75
  - a **`madeby ai`** attestation (your own AI session's witnessed spans).
78
76
 
79
- Exit codes: **0** = pass · **1** = a `required` policy failed on an undisclosed commit · **2** = not
80
- a git repository. Under `advisory` it only reports (always exit 0) — so it's safe to add to CI
77
+ A **DCO `Signed-off-by:`** line or a **commit signature** is recognized too, but on its own it says
78
+ who is accountable for the commit, not who wrote it, so it counts only if the policy's `accept`
79
+ lists it. `check` reports commits that carry only one of those apart from those that carry nothing.
80
+
81
+ Exit codes: **0** = pass · **1** = a `required` policy failed on an undisclosed commit, or couldn't
82
+ read the history (below) · **2** = not a git repository. Under `advisory` it only reports (always exit 0) — so it's safe to add to CI
81
83
  before you decide to enforce anything.
82
84
 
83
85
  In CI, omit `<range>` — on a pull-request build `madeby check` **auto-scopes to the PR's own
84
- commits** from the CI environment (GitHub today; other hosts via their adapter). Pass an explicit
85
- `<range>` to override.
86
+ commits** from the CI environment (GitHub, Azure DevOps and GitLab). Pass an explicit `<range>` to
87
+ override.
88
+
89
+ **The history has to be there.** On a shallow clone, when a range endpoint isn't in the clone (a
90
+ target branch that was never fetched), or on a pull-request build whose range can't be read, `check`
91
+ says so: under `required` it fails rather than score whatever happens to be present, and under
92
+ `advisory` it prints a warning. Check out the full history (`fetch-depth: 0` on GitHub,
93
+ `fetchDepth: 0` on Azure Pipelines); the files `madeby init` writes already do.
86
94
 
87
95
  ### `madeby coverage` — the authorship scoreboard
88
96
 
@@ -92,6 +100,7 @@ history's authorship is **on the record** — no policy, no config:
92
100
  ```sh
93
101
  madeby coverage # scores recent history
94
102
  madeby coverage origin/main..HEAD # or a range
103
+ madeby coverage --by author # the same, one row per commit author
95
104
  ```
96
105
 
97
106
  ```
@@ -115,7 +124,8 @@ inferred as human — disclosure, not detection. The human/with_ai/ai mix is rep
115
124
  never of the whole repo, and the asserted-vs-verified split shows how much is signed. The agents and
116
125
  model versions are listed exactly as the AI commits name them — a commit naming a tool and its model
117
126
  (`Assisted-by: Claude Code` + `Co-Authored-By: Claude Opus 5.5`) counts under both, so those rows can
118
- sum past 100%. `--json` emits
127
+ sum past 100%. `--by author` shows one row per commit author (grouped by email): how many of their
128
+ commits disclose, and the mix — who discloses and who never does. `--json` (which includes `byAuthor`) emits
119
129
  counts only (percentages are derived at the edge, never stored). A plain merge authors nothing and
120
130
  isn't scored; a merge that changes content of its own (a conflict resolution) is, like any other
121
131
  commit — chosen by what it changes, never by whether it discloses. Use it to see your gap, then
@@ -138,6 +148,25 @@ of AI disclosure); a sworn declaration is *sworn*. The one asymmetry is evidence
138
148
  leave a re-checkable artifact (a session log, `madeby ai`); human authorship is your firsthand
139
149
  testimony. If you used AI, disclose the AI — don't claim human.
140
150
 
151
+ ### Disclosing earlier commits — `--for`
152
+
153
+ `me` and `ai` amend HEAD. When that isn't possible — the commits are already pushed, or the repo
154
+ forbids amending and rebasing — disclose them with **one new commit** instead:
155
+
156
+ ```sh
157
+ madeby me --with-ai "Claude Code" --for origin/dev..HEAD # your commits on this branch
158
+ madeby ai "Claude Code" --for 4f1c2a9e # one commit
159
+ ```
160
+
161
+ It makes an empty **disclosure commit** whose `Discloses: <hash>` lines name the commits and whose
162
+ trailers say who wrote them. Nothing is rewritten. Push it as usual: on a pull request the check
163
+ re-runs, and it travels with the branch into every target it's merged into. Only your own commits
164
+ (author email = `git config user.email`) that don't disclose yet are included; the rest are listed
165
+ as skipped. The model is named only if you pass `--model`, since the session you're in now didn't
166
+ write those commits. `check`, `coverage` and `who` read disclosure commits by the rules in
167
+ [ACO §5](https://github.com/MadeByFYI/MadeBy/blob/main/ACO.md#5-carriers-bindings--subsume-dont-compete), and `coverage` counts how many
168
+ disclosures arrived late.
169
+
141
170
  **The policy file is optional.** With no `.madeby/policy.json`, `check` defaults to **advisory** —
142
171
  it reports coverage and never blocks, so you can run it (or add the CI check) with zero config. Add
143
172
  the file only when you want to enforce:
@@ -151,8 +180,8 @@ Two modes, nothing more:
151
180
 
152
181
  - `advisory` — report coverage on each run; never fails. **The default** (and what you get with no file).
153
182
  - `required` — fail on any commit that discloses nothing your policy accepts.
154
- - optional `"accept": ["dco-signoff", "ai-trailer", …]` — restrict what satisfies the policy
155
- (omit ⇒ any recognized disclosure counts).
183
+ - optional `"accept": ["dco-signoff", "ai-trailer", …]` — set what satisfies the policy
184
+ (omit ⇒ any authorship disclosure counts; a DCO sign-off or signature alone counts only if listed).
156
185
  - optional `"recordModel": true` — written by `madeby init --record-model`: the repo asks AI
157
186
  disclosures to name the model version as well as the tool. It adds one line to the AGENTS.md rule,
158
187
  and `me --with-ai` / `ai` then read the model from the **tool's own record of the session** — Cursor's
@@ -166,7 +195,8 @@ Two modes, nothing more:
166
195
  (There's no "off" — that's just not running the check. Advisory is the floor.)
167
196
 
168
197
  Fail-safe by design: a missing or malformed policy degrades to `advisory`. `madeby` never blocks a PR
169
- on its own error, and never invents a stricter gate than the maintainer wrote.
198
+ on its own error, and never invents a stricter gate than the maintainer wrote. It never passes a
199
+ `required` check it couldn't actually run, either: missing history fails it (see `check` above).
170
200
 
171
201
  ## Use it as a PR check
172
202
 
package/dist/madeby.mjs CHANGED
@@ -16,6 +16,26 @@ var parseMeta = (rec) => {
16
16
  message: parts.slice(4).join(FIELD).trimEnd()
17
17
  };
18
18
  };
19
+ function historyProblem(repoPath, range) {
20
+ const git5 = (...args) => {
21
+ try {
22
+ return execFileSync("git", ["-C", repoPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
23
+ } catch {
24
+ return null;
25
+ }
26
+ };
27
+ if (git5("rev-parse", "--is-shallow-repository") === "true") {
28
+ return "this is a shallow clone, so the history may be cut short. Fetch the full history (Azure Pipelines: fetchDepth: 0 on the checkout step; GitHub Actions: fetch-depth: 0).";
29
+ }
30
+ if (range) {
31
+ for (const end of range.split(/\.{2,3}/).filter(Boolean)) {
32
+ if (git5("rev-parse", "--verify", "--quiet", `${end}^{commit}`) === null) {
33
+ return `the range ${range} can't be read: ${end} isn't in this clone. Fetch it (full history), or pass a range that exists.`;
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
19
39
  function readGitLog(repoPath, limit, range) {
20
40
  try {
21
41
  const args = [
@@ -873,6 +893,27 @@ var conformance_v0_default = {
873
893
  var CONFORMANCE_VERSION = conformance_v0_default.version;
874
894
 
875
895
  // ../core/src/disclosure.ts
896
+ var DISCLOSURE_KINDS = {
897
+ "ai-trailer": { label: "AI-authorship trailer", nativeness: "external", ceiling: "asserted", parser: "recognized", authorship: true },
898
+ // The AFFIRMATIVE human-authorship claim — the symmetric counterpart of the AI trailer, NOT a
899
+ // separate mechanism: same recognizer, same ladder. A bare trailer is `asserted` (identical to the
900
+ // AI trailer); sign it → verified, swear it → sworn — the signature authenticates WHO is claiming
901
+ // (invariant #10), equally for human or AI disclosure. The one genuine asymmetry is EVIDENCE vs.
902
+ // tier: AI authorship can leave a re-checkable ARTIFACT (a session log matched to the code,
903
+ // `prove`); human authorship is the author's firsthand TESTIMONY — real, but not independently
904
+ // re-checkable, so to a third party it lands as an attestation (asserted → signed → sworn testimony).
905
+ "human-attestation": { label: "human-authorship attestation", nativeness: "madeby", ceiling: "asserted", parser: "recognized", authorship: true },
906
+ "dco-signoff": { label: "DCO Signed-off-by", nativeness: "external", ceiling: "asserted", parser: "recognized", authorship: false },
907
+ "commit-signature": { label: "commit signature", nativeness: "external", ceiling: "verified", parser: "recognized", authorship: false },
908
+ "ai-tool-config": { label: "committed AI-tool config", nativeness: "external", ceiling: "asserted", parser: "recognized", authorship: false },
909
+ "spdx-license": { label: "SPDX license metadata", nativeness: "external", ceiling: "asserted", parser: "recognized", authorship: false },
910
+ "spdx-ai-disclosure": { label: "SPDX-AI-Disclosure tag", nativeness: "external", ceiling: "asserted", parser: "recognized", authorship: true },
911
+ reuse: { label: "REUSE licensing", nativeness: "external", ceiling: "asserted", parser: "planned", authorship: false },
912
+ "in-toto": { label: "in-toto attestation", nativeness: "external", ceiling: "verified", parser: "planned", authorship: false },
913
+ slsa: { label: "SLSA provenance", nativeness: "external", ceiling: "verified", parser: "planned", authorship: false },
914
+ "madeby-manifest": { label: "MadeBy witnessed manifest", nativeness: "madeby", ceiling: "asserted", parser: "recognized", authorship: true },
915
+ "madeby-declaration": { label: "MadeBy self-hosted declaration", nativeness: "madeby", ceiling: "sworn", parser: "recognized", authorship: true }
916
+ };
876
917
  var AI_TRAILER_RE = /^[ \t]*(?:Co-authored-by|Generated-by|Assisted-by)[ \t]*:[ \t]*(.+?)[ \t]*$/gim;
877
918
  var AI_MARKER_RE = /\b(?:claude|copilot|cursor(?:agent)?|devin|gemini|chatgpt|openai|codex|gpt-|codeium|windsurf|aider|codewhisperer|tabnine)\b/i;
878
919
  function hasAiTrailer(message) {
@@ -930,6 +971,16 @@ function commitDisclosureKinds(commit) {
930
971
  if (commit.signed === true) kinds.push("commit-signature");
931
972
  return kinds;
932
973
  }
974
+ function hasAuthorshipDisclosure(message) {
975
+ return commitDisclosureKinds({ message }).some((k) => DISCLOSURE_KINDS[k].authorship);
976
+ }
977
+ var DISCLOSES_RE = /^[ \t]*Discloses[ \t]*:[ \t]*([0-9a-f]{64}|[0-9a-f]{40})[ \t]*$/gim;
978
+ function disclosedTargets(message) {
979
+ return [...new Set([...message.matchAll(DISCLOSES_RE)].map((m) => m[1].toLowerCase()))];
980
+ }
981
+ function disclosureCommitTrailers(message) {
982
+ return message.split("\n").slice(1).filter((l) => !/^[ \t]*Discloses[ \t]*:/i.test(l)).join("\n").trim();
983
+ }
933
984
 
934
985
  // ../core/src/policy.ts
935
986
  var DEFAULT_POLICY = { version: 0, mode: "advisory" };
@@ -947,18 +998,22 @@ function parseDisclosurePolicy(input) {
947
998
  ...o.recordModel === true ? { recordModel: true } : {}
948
999
  };
949
1000
  }
1001
+ function acceptsKind(policy, kind) {
1002
+ return policy.accept ? policy.accept.includes(kind) : DISCLOSURE_KINDS[kind]?.authorship === true;
1003
+ }
950
1004
  function evaluateDisclosurePolicy(policy, commits) {
951
1005
  const accept = policy.accept;
952
- const isAccepted = (k) => !accept || accept.includes(k);
953
1006
  const verdicts = commits.map((c) => {
954
- const satisfiedBy = c.kinds.filter(isAccepted);
955
- return { ref: c.ref, compliant: satisfiedBy.length > 0, satisfiedBy };
1007
+ const satisfiedBy = c.kinds.filter((k) => acceptsKind(policy, k));
1008
+ const notAccepted = c.kinds.filter((k) => !acceptsKind(policy, k));
1009
+ return { ref: c.ref, compliant: satisfiedBy.length > 0, satisfiedBy, notAccepted };
956
1010
  });
957
1011
  const nonCompliant = verdicts.filter((v) => !v.compliant);
958
1012
  const compliant = verdicts.length - nonCompliant.length;
959
1013
  const pass = policy.mode !== "required" || nonCompliant.length === 0;
960
- const wanted = accept ? accept.join(", ") : "any recognized disclosure";
961
- const summary = `${compliant}/${verdicts.length} commits disclose origin (${policy.mode}; accepts: ${wanted}).` + (nonCompliant.length ? ` ${nonCompliant.length} undisclosed.` : "");
1014
+ const wanted = accept ? accept.join(", ") : "any authorship disclosure";
1015
+ const otherOnly = nonCompliant.filter((v) => v.notAccepted.length > 0).length;
1016
+ const summary = `${compliant}/${verdicts.length} commits disclose origin (${policy.mode}; accepts: ${wanted}).` + (nonCompliant.length ? ` ${nonCompliant.length} undisclosed` + (otherOnly ? ` (${otherOnly} carry only a sign-off or signature, which don't say who wrote the code).` : ".") : "");
962
1017
  return { mode: policy.mode, pass, total: verdicts.length, compliant, nonCompliant, summary };
963
1018
  }
964
1019
 
@@ -977,6 +1032,10 @@ function detectCiRange(env = process.env) {
977
1032
  }
978
1033
  return null;
979
1034
  }
1035
+ function detectCiPullRequest(env = process.env) {
1036
+ for (const adapter of registry.values()) if (adapter.ciPullRequest?.(env)) return adapter.id;
1037
+ return null;
1038
+ }
980
1039
 
981
1040
  // ../analyzer/src/host/github.ts
982
1041
  var NOREPLY = /^(?:(\d+)\+)?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))@users\.noreply\.github\.com$/i;
@@ -996,8 +1055,11 @@ var githubAdapter = {
996
1055
  // On a `pull_request` event GitHub sets GITHUB_EVENT_NAME + GITHUB_EVENT_PATH (a JSON payload with
997
1056
  // the PR's base/head SHAs). Reading base..head here replaces the bash range computation the Action
998
1057
  // used to carry — the range logic now lives once, in the adapter. Needs the PR history present
999
- // (the quickstart's `fetch-depth: 0`). Any absence/parse failure → null (fall back to recent
1000
- // history); never throws.
1058
+ // (the quickstart's `fetch-depth: 0`). Any absence/parse failure → null (`check` reports the
1059
+ // unreadable PR build, see ciPullRequest); never throws.
1060
+ ciPullRequest(env) {
1061
+ return env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target";
1062
+ },
1001
1063
  ciRange(env) {
1002
1064
  const event = env.GITHUB_EVENT_NAME;
1003
1065
  if (event !== "pull_request" && event !== "pull_request_target") return null;
@@ -1018,13 +1080,85 @@ registerHostAdapter(githubAdapter);
1018
1080
  // ../analyzer/src/read-disclosures.ts
1019
1081
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1020
1082
  import { join } from "node:path";
1083
+
1084
+ // ../analyzer/src/late-disclosure.ts
1085
+ import { execFileSync as execFileSync2 } from "node:child_process";
1086
+ var FIELD2 = "";
1087
+ var RECORD2 = "";
1088
+ var git = (root, args) => {
1089
+ try {
1090
+ return execFileSync2("git", ["-C", root, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1091
+ } catch {
1092
+ return null;
1093
+ }
1094
+ };
1095
+ function rangeHead(range) {
1096
+ if (!range) return "HEAD";
1097
+ const parts = range.split(/\.{2,3}/);
1098
+ return parts[parts.length - 1] || "HEAD";
1099
+ }
1100
+ function readDisclosureCommits(root, head = "HEAD") {
1101
+ const out = { applied: /* @__PURE__ */ new Map(), disclosureCommits: /* @__PURE__ */ new Set(), rejected: [] };
1102
+ const log = git(root, ["log", "--no-merges", "-E", "--grep=^[ ]*Discloses[ ]*:[ ]*[0-9a-f]{40}", `--format=%H${FIELD2}%ae${FIELD2}%B${RECORD2}`, head]);
1103
+ if (!log) return out;
1104
+ const candidates = log.split(RECORD2).map((r) => r.replace(/^\s+/, "")).filter(Boolean).map((r) => {
1105
+ const [sha = "", email = "", ...msg] = r.split(FIELD2);
1106
+ return { sha, email: email.toLowerCase(), message: msg.join(FIELD2) };
1107
+ });
1108
+ for (const d of candidates) {
1109
+ const targets = disclosedTargets(d.message);
1110
+ if (targets.length === 0) continue;
1111
+ const changed = git(root, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", d.sha]);
1112
+ if (changed === null || changed.trim() !== "") continue;
1113
+ out.disclosureCommits.add(d.sha);
1114
+ const trailers = disclosureCommitTrailers(d.message);
1115
+ const emails = /* @__PURE__ */ new Map();
1116
+ const shown = git(root, ["show", "-s", "--format=%H %ae", ...targets]) ?? targets.map((t) => git(root, ["show", "-s", "--format=%H %ae", t]) ?? "").join("\n");
1117
+ for (const line of shown.split("\n")) {
1118
+ const [sha, email] = line.trim().split(" ");
1119
+ if (sha && email !== void 0) emails.set(sha, email.toLowerCase());
1120
+ }
1121
+ for (const t of targets) {
1122
+ if (out.applied.has(t)) continue;
1123
+ const email = emails.get(t);
1124
+ if (email === void 0) out.rejected.push({ sha: t, via: d.sha, reason: "unknown-commit" });
1125
+ else if (email !== d.email) out.rejected.push({ sha: t, via: d.sha, reason: "other-author" });
1126
+ else if (git(root, ["merge-base", "--is-ancestor", t, d.sha]) === null) out.rejected.push({ sha: t, via: d.sha, reason: "not-an-ancestor" });
1127
+ else out.applied.set(t, { via: d.sha, trailers });
1128
+ }
1129
+ }
1130
+ return out;
1131
+ }
1132
+ function applyLateDisclosures(commits, d) {
1133
+ const out = [];
1134
+ for (const c of commits) {
1135
+ const sha = (c.sha ?? "").toLowerCase();
1136
+ if (d.disclosureCommits.has(sha)) continue;
1137
+ const late = d.applied.get(sha);
1138
+ if (late && !hasAuthorshipDisclosure(c.message)) out.push({ ...c, message: `${c.message}
1139
+
1140
+ ${late.trailers}`, late: late.via });
1141
+ else out.push(c);
1142
+ }
1143
+ return out;
1144
+ }
1145
+
1146
+ // ../analyzer/src/read-disclosures.ts
1021
1147
  function readDisclosures(root, opts = {}) {
1022
- const commits = readGitLog(root, opts.range ? void 0 : opts.limit ?? 200, opts.range);
1023
- return commits.map((c) => ({
1024
- sha: c.sha ?? "",
1025
- subject: c.message.split("\n")[0] ?? "",
1026
- disclosures: commitDisclosureKinds({ message: c.message, signed: c.signed })
1027
- }));
1148
+ return readDisclosuresWithRejects(root, opts).commits;
1149
+ }
1150
+ function readDisclosuresWithRejects(root, opts) {
1151
+ const late = readDisclosureCommits(root, rangeHead(opts.range));
1152
+ const commits = applyLateDisclosures(readGitLog(root, opts.range ? void 0 : opts.limit ?? 200, opts.range), late);
1153
+ return {
1154
+ commits: commits.map((c) => ({
1155
+ sha: c.sha ?? "",
1156
+ subject: c.message.split("\n")[0] ?? "",
1157
+ disclosures: commitDisclosureKinds({ message: c.message, signed: c.signed }),
1158
+ ...c.late ? { late: c.late } : {}
1159
+ })),
1160
+ rejected: late.rejected
1161
+ };
1028
1162
  }
1029
1163
  function evaluateRepoDisclosure(root, opts = {}) {
1030
1164
  let policy = DEFAULT_POLICY;
@@ -1037,7 +1171,7 @@ function evaluateRepoDisclosure(root, opts = {}) {
1037
1171
  warning = ".madeby/policy.json is not valid JSON \u2014 degrading to advisory (report-only, never blocks).";
1038
1172
  }
1039
1173
  }
1040
- const commits = readDisclosures(root, opts);
1174
+ const { commits, rejected } = readDisclosuresWithRejects(root, opts);
1041
1175
  const disclosures = commits.map((c) => ({ ref: (c.sha || "").slice(0, 8) || "(unknown)", kinds: c.disclosures }));
1042
1176
  const result = evaluateDisclosurePolicy(policy, disclosures);
1043
1177
  const subjectFor = (ref) => commits.find((c) => (c.sha || "").startsWith(ref))?.subject ?? "";
@@ -1047,12 +1181,13 @@ function evaluateRepoDisclosure(root, opts = {}) {
1047
1181
  summary: result.summary,
1048
1182
  total: disclosures.length,
1049
1183
  undisclosed: result.nonCompliant.map((v) => ({ ref: v.ref, subject: subjectFor(v.ref) })),
1184
+ lateRejected: rejected.filter((r) => commits.some((c) => c.sha === r.sha)),
1050
1185
  ...warning ? { warning } : {}
1051
1186
  };
1052
1187
  }
1053
1188
 
1054
1189
  // ../analyzer/src/path-provenance.ts
1055
- import { execFileSync as execFileSync2 } from "node:child_process";
1190
+ import { execFileSync as execFileSync3 } from "node:child_process";
1056
1191
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
1057
1192
  import { join as join3 } from "node:path";
1058
1193
 
@@ -1089,13 +1224,13 @@ function readSpanManifestsFromDir(repoDir) {
1089
1224
 
1090
1225
  // ../analyzer/src/path-provenance.ts
1091
1226
  var overlaps = (aS, aE, bS, bE) => bS === void 0 || bE === void 0 || aS <= bE && aE >= bS;
1092
- function git(root, args) {
1093
- return execFileSync2("git", ["-C", root, "-c", "core.quotePath=false", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1227
+ function git2(root, args) {
1228
+ return execFileSync3("git", ["-C", root, "-c", "core.quotePath=false", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
1094
1229
  }
1095
1230
  var US = "";
1096
1231
  function commitMeta(root, sha) {
1097
1232
  try {
1098
- const out = git(root, ["show", "-s", `--format=%G?${US}%an${US}%ae${US}%B`, sha]);
1233
+ const out = git2(root, ["show", "-s", `--format=%G?${US}%an${US}%ae${US}%B`, sha]);
1099
1234
  const parts = out.split(US);
1100
1235
  const sig = parts[0] ?? "N";
1101
1236
  return { signed: sig !== "N" && sig !== "", name: parts[1] ?? "", email: parts[2] ?? "", message: (parts.slice(3).join(US) ?? "").trim() };
@@ -1165,12 +1300,17 @@ function provenanceOf(root, path, opts = {}) {
1165
1300
  if (opts.useBlame !== false) {
1166
1301
  try {
1167
1302
  const range = opts.startLine !== void 0 ? [`-L`, `${opts.startLine},${opts.endLine ?? opts.startLine}`] : [];
1168
- const porcelain = git(root, ["blame", "--porcelain", ...range, "--", path]);
1303
+ const porcelain = git2(root, ["blame", "--porcelain", ...range, "--", path]);
1169
1304
  const metaCache = /* @__PURE__ */ new Map();
1305
+ const late = readDisclosureCommits(root);
1170
1306
  for (const g of blameGroups(porcelain)) {
1171
1307
  let meta = metaCache.get(g.sha);
1172
1308
  if (meta === void 0) {
1173
1309
  meta = commitMeta(root, g.sha);
1310
+ const filled = meta && late.applied.get(g.sha.toLowerCase());
1311
+ if (meta && filled && !hasAuthorshipDisclosure(meta.message)) meta = { ...meta, message: `${meta.message}
1312
+
1313
+ ${filled.trailers}` };
1174
1314
  metaCache.set(g.sha, meta);
1175
1315
  }
1176
1316
  if (!meta) continue;
@@ -1221,7 +1361,7 @@ function provenanceMap(root, opts = {}) {
1221
1361
  const witnessed = [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
1222
1362
  let trackedFiles = 0;
1223
1363
  try {
1224
- trackedFiles = git(root, ["ls-files", ...prefix ? ["--", prefix] : []]).split("\n").filter(Boolean).length;
1364
+ trackedFiles = git2(root, ["ls-files", ...prefix ? ["--", prefix] : []]).split("\n").filter(Boolean).length;
1225
1365
  } catch {
1226
1366
  }
1227
1367
  const commits = readDisclosures(root, { limit: opts.commitLimit ?? 500 });
@@ -1261,17 +1401,26 @@ function repoCoverage(root, opts = {}) {
1261
1401
  const oldest = authored.at(-1)?.sha;
1262
1402
  const since = limit && oldest && authored.length >= limit ? commitTime(root, oldest) : void 0;
1263
1403
  const merges = authored.length > 0 && (!limit || authored.length < limit || since !== void 0) ? readAuthoredMerges(root, { range: opts.range, since }) : [];
1264
- const commits = [...authored, ...merges];
1404
+ const commits = applyLateDisclosures([...authored, ...merges], readDisclosureCommits(root, rangeHead(opts.range)));
1265
1405
  const byCategory = { human: 0, with_ai: 0, ai: 0, bot: 0 };
1266
1406
  const byTier = { asserted: 0, verified: 0 };
1267
1407
  let known = 0;
1408
+ let late = 0;
1268
1409
  const agents = /* @__PURE__ */ new Map();
1410
+ const authors = /* @__PURE__ */ new Map();
1269
1411
  for (const c of commits) {
1270
1412
  const kinds = commitDisclosureKinds({ message: c.message, signed: c.signed });
1271
1413
  const { class: cls, aiContributors } = classifyCommit({ message: c.message, authorName: c.authorName, authorEmail: c.authorEmail, signed: c.signed, sha: c.sha });
1272
1414
  const category = cls === "human" && !kinds.includes("human-attestation") ? "unknown" : cls;
1415
+ const email = (c.authorEmail ?? "").toLowerCase();
1416
+ const author = authors.get(email) ?? { name: c.authorName ?? email, email, commits: 0, known: 0, byCategory: { human: 0, with_ai: 0, ai: 0, bot: 0 } };
1417
+ author.commits += 1;
1418
+ authors.set(email, author);
1273
1419
  if (category === "unknown") continue;
1420
+ author.known += 1;
1421
+ author.byCategory[category] += 1;
1274
1422
  known += 1;
1423
+ if (c.late) late += 1;
1275
1424
  byCategory[category] += 1;
1276
1425
  if (c.signed) byTier.verified += 1;
1277
1426
  else byTier.asserted += 1;
@@ -1291,19 +1440,21 @@ function repoCoverage(root, opts = {}) {
1291
1440
  }
1292
1441
  return {
1293
1442
  isEstimate: true,
1294
- methodology: "commit-level authorship disclosure (estimate); undisclosed = unknown, never inferred; merges scored only when they change content of their own",
1443
+ methodology: "commit-level authorship disclosure (estimate); undisclosed = unknown, never inferred; merges scored only when they change content of their own; a disclosure commit (Discloses:) fills in commits disclosed afterwards and isn't scored itself",
1295
1444
  scope: (opts.range ? `range ${opts.range}` : `last ${authored.length} commits`) + (merges.length ? ` + ${merges.length} merges with changes of their own` : ""),
1296
1445
  total: commits.length,
1297
1446
  known,
1298
1447
  unknown: commits.length - known,
1448
+ late,
1299
1449
  byCategory,
1300
1450
  byTier,
1301
- byAgent: [...agents.values()].sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name))
1451
+ byAgent: [...agents.values()].sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name)),
1452
+ byAuthor: [...authors.values()].sort((a, b) => b.commits - a.commits || a.email.localeCompare(b.email))
1302
1453
  };
1303
1454
  }
1304
1455
 
1305
1456
  // ../analyzer/src/record-spans.ts
1306
- import { execFileSync as execFileSync3 } from "node:child_process";
1457
+ import { execFileSync as execFileSync4 } from "node:child_process";
1307
1458
  import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync, writeFileSync } from "node:fs";
1308
1459
  import { homedir } from "node:os";
1309
1460
  import { dirname, join as join4 } from "node:path";
@@ -1636,8 +1787,8 @@ ${body}
1636
1787
  }
1637
1788
 
1638
1789
  // ../analyzer/src/record-spans.ts
1639
- function git2(root, ...a) {
1640
- return execFileSync3("git", ["-C", root, "-c", "core.quotePath=false", ...a], { encoding: "utf8" }).trim();
1790
+ function git3(root, ...a) {
1791
+ return execFileSync4("git", ["-C", root, "-c", "core.quotePath=false", ...a], { encoding: "utf8" }).trim();
1641
1792
  }
1642
1793
  function autodiscover(root) {
1643
1794
  const enc3 = root.replace(/[/_]/g, "-");
@@ -1653,7 +1804,7 @@ function recordAiSpans(root, opts = {}) {
1653
1804
  if (!transcriptPath || !existsSync3(transcriptPath)) {
1654
1805
  return { written: false, matched: 0, discarded: 0, message: "transcript not found \u2014 pass a transcript path", error: "transcript-not-found" };
1655
1806
  }
1656
- const commit = git2(root, "rev-parse", opts.ref ?? "HEAD");
1807
+ const commit = git3(root, "rev-parse", opts.ref ?? "HEAD");
1657
1808
  const transcriptText = readFileSync5(transcriptPath, "utf8");
1658
1809
  const parser = detectParser(transcriptText);
1659
1810
  if (!parser) {
@@ -1671,7 +1822,7 @@ function recordAiSpans(root, opts = {}) {
1671
1822
  }
1672
1823
  },
1673
1824
  commit,
1674
- operatorId: git2(root, "show", "-s", "--format=%ae", commit),
1825
+ operatorId: git3(root, "show", "-s", "--format=%ae", commit),
1675
1826
  now: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
1676
1827
  });
1677
1828
  if (result.manifest.attestations.length === 0) {
@@ -1745,7 +1896,11 @@ var azureAdapter = {
1745
1896
  // these optional precisely so an adapter can omit what its host doesn't offer.
1746
1897
  // Azure Pipelines sets these predefined variables on a PR-triggered build. The target branch is
1747
1898
  // given as a ref (not a SHA), so the range references origin/<target>; the pipeline template uses
1748
- // fetchDepth: 0 so that ref + the PR history are present. Any absence → null (recent history).
1899
+ // fetchDepth: 0 so that ref + the PR history are present. Any absence → null; `check` then reports
1900
+ // the PR build whose range it couldn't read (ciPullRequest) instead of passing on recent history.
1901
+ ciPullRequest(env) {
1902
+ return env.BUILD_REASON === "PullRequest";
1903
+ },
1749
1904
  ciRange(env) {
1750
1905
  if (env.BUILD_REASON !== "PullRequest") return null;
1751
1906
  const targetRef = env.SYSTEM_PULLREQUEST_TARGETBRANCH;
@@ -1771,7 +1926,10 @@ var gitlabAdapter = {
1771
1926
  },
1772
1927
  // GitLab CI sets these predefined variables on a merge-request pipeline. CI_MERGE_REQUEST_DIFF_BASE_SHA
1773
1928
  // is the merge-base of the MR; CI_COMMIT_SHA is the head — so base..head is the MR's own commits.
1774
- // (Needs the MR pipeline / a deep-enough fetch for those SHAs.) Any absence → null (recent history).
1929
+ // (Needs the MR pipeline / a deep-enough fetch for those SHAs.) Any absence → null (reported by `check`).
1930
+ ciPullRequest(env) {
1931
+ return env.CI_PIPELINE_SOURCE === "merge_request_event";
1932
+ },
1775
1933
  ciRange(env) {
1776
1934
  if (env.CI_PIPELINE_SOURCE !== "merge_request_event") return null;
1777
1935
  const base = env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
@@ -1783,10 +1941,10 @@ var gitlabAdapter = {
1783
1941
  registerHostAdapter(gitlabAdapter);
1784
1942
 
1785
1943
  // src/scope.ts
1786
- import { execFileSync as execFileSync4 } from "node:child_process";
1944
+ import { execFileSync as execFileSync5 } from "node:child_process";
1787
1945
  function repoRoot() {
1788
1946
  try {
1789
- return execFileSync4("git", ["-c", "core.quotePath=false", "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1947
+ return execFileSync5("git", ["-c", "core.quotePath=false", "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
1790
1948
  } catch {
1791
1949
  return null;
1792
1950
  }
@@ -1809,9 +1967,23 @@ function checkCommand(args) {
1809
1967
  }
1810
1968
  const { range, autoHost } = resolveRange(explicit);
1811
1969
  const result = evaluateRepoDisclosure(root, { range });
1970
+ const prHost = explicit || autoHost ? null : detectCiPullRequest();
1971
+ const problem = prHost ? `this is a ${prHost} pull-request build, but its commit range couldn't be read from the CI environment. Pass the range explicitly: madeby check <base>..<head>` : historyProblem(root, range);
1972
+ const blocked = problem !== null && result.mode === "required";
1973
+ const pass = result.pass && !blocked;
1812
1974
  if (json) {
1813
- console.log(JSON.stringify({ range: range ?? null, autoHost: autoHost ?? null, ...result }, null, 2));
1814
- process.exit(result.pass ? 0 : 1);
1975
+ console.log(JSON.stringify({ range: range ?? null, autoHost: autoHost ?? null, ...result, pass, ...problem ? { historyProblem: problem } : {} }, null, 2));
1976
+ process.exit(pass ? 0 : 1);
1977
+ return;
1978
+ }
1979
+ if (problem) {
1980
+ const banner = blocked ? "FAILED \u2014 can't check this history" : "WARNING \u2014 this result may be wrong";
1981
+ console.error(`
1982
+ madeby check: ${banner}: ${problem}
1983
+ `);
1984
+ }
1985
+ if (blocked) {
1986
+ process.exit(1);
1815
1987
  return;
1816
1988
  }
1817
1989
  if (result.warning) console.warn(`madeby check: ${result.warning}`);
@@ -1824,16 +1996,20 @@ Undisclosed commits (${result.undisclosed.length}):`);
1824
1996
  console.log(` \u2717 ${v.ref} ${v.subject.slice(0, 60)}`);
1825
1997
  }
1826
1998
  if (result.undisclosed.length > 20) console.log(` \u2026 and ${result.undisclosed.length - 20} more`);
1999
+ const why = { "other-author": "it names another author's commit", "not-an-ancestor": "it names a commit that isn't before it", "unknown-commit": "it names a commit that isn't here" };
2000
+ for (const r of result.lateRejected.slice(0, 20)) {
2001
+ console.log(` ! ${r.sha.slice(0, 8)} is named by disclosure commit ${r.via.slice(0, 8)}, which can't disclose it: ${why[r.reason]}.`);
2002
+ }
1827
2003
  console.log(
1828
2004
  `
1829
- To disclose: add a Co-Authored-By / Generated-by trailer, affirm your own authorship with 'madeby me', add a DCO Signed-off-by, sign the commit, or record your AI session with 'madeby ai'. (Disclosure, not a ban \u2014 what this project requires is set in .madeby/policy.json.)`
2005
+ To disclose who wrote it: 'madeby ai <tool>' (an AI wrote it), 'madeby me --with-ai <tool>' (you, with AI), or 'madeby me' (you alone); a Co-Authored-By trailer naming an AI tool also counts. A DCO sign-off or a signature alone doesn't say who wrote the code. Already pushed, or no amending allowed? Add --for <range> (e.g. madeby me --with-ai <tool> --for origin/main..HEAD): one new commit discloses the earlier ones, nothing rewritten. (Disclosure, not a ban \u2014 what this project requires is set in .madeby/policy.json.)`
1830
2006
  );
1831
2007
  }
1832
2008
  process.exit(result.pass ? 0 : 1);
1833
2009
  }
1834
2010
 
1835
2011
  // src/commands/me.ts
1836
- import { execFileSync as execFileSync5 } from "node:child_process";
2012
+ import { execFileSync as execFileSync6 } from "node:child_process";
1837
2013
  import { existsSync as existsSync5, readFileSync as readFileSync7 } from "node:fs";
1838
2014
  import { homedir as homedir3 } from "node:os";
1839
2015
  import { join as join6 } from "node:path";
@@ -1952,8 +2128,8 @@ function parseWithAi(args) {
1952
2128
  const next = args[i + 1];
1953
2129
  return { withAi: true, tool: next && !next.startsWith("-") ? next : "AI" };
1954
2130
  }
1955
- function git3(root, args) {
1956
- return execFileSync5("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
2131
+ function git4(root, args) {
2132
+ return execFileSync6("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
1957
2133
  }
1958
2134
  var HUMAN_ATTEST2 = /^[ \t]*(?:Authored-by-human|Human-authored(?:-by)?)[ \t]*:/im;
1959
2135
  var ASSIST = /^[ \t]*Assisted-by[ \t]*:/im;
@@ -1979,15 +2155,15 @@ function attestAiAuthorship(root, opts = {}) {
1979
2155
  const tool = ai.value;
1980
2156
  const base = { ok: false, changed: false, withAi: false, tool, model: ai.model, modelSource: ai.modelSource, added: [], category: "ai" };
1981
2157
  try {
1982
- execFileSync5("git", ["-C", root, "diff", "--cached", "--quiet"]);
2158
+ execFileSync6("git", ["-C", root, "diff", "--cached", "--quiet"]);
1983
2159
  } catch {
1984
2160
  return { ...base, reason: "staged-changes" };
1985
2161
  }
1986
2162
  let subject = "";
1987
2163
  let message = "";
1988
2164
  try {
1989
- subject = git3(root, ["show", "-s", "--format=%s", "HEAD"]);
1990
- message = git3(root, ["show", "-s", "--format=%B", "HEAD"]);
2165
+ subject = git4(root, ["show", "-s", "--format=%s", "HEAD"]);
2166
+ message = git4(root, ["show", "-s", "--format=%B", "HEAD"]);
1991
2167
  } catch {
1992
2168
  return { ...base, reason: "no-commit" };
1993
2169
  }
@@ -1998,7 +2174,7 @@ function attestAiAuthorship(root, opts = {}) {
1998
2174
  added.push("Authored-by-ai");
1999
2175
  }
2000
2176
  if (trailers.length > 0) {
2001
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2177
+ execFileSync6("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2002
2178
  }
2003
2179
  return { ...base, ok: true, changed: trailers.length > 0, subject, added };
2004
2180
  }
@@ -2010,8 +2186,8 @@ function disclosureTrailers(root, opts) {
2010
2186
  let name = "";
2011
2187
  let email = "";
2012
2188
  try {
2013
- name = git3(root, ["config", "user.name"]);
2014
- email = git3(root, ["config", "user.email"]);
2189
+ name = git4(root, ["config", "user.name"]);
2190
+ email = git4(root, ["config", "user.email"]);
2015
2191
  } catch {
2016
2192
  }
2017
2193
  if (!name || !email) return { ok: false, reason: "no-identity", lines: [] };
@@ -2028,21 +2204,21 @@ function affirmAuthorship(root, opts = {}) {
2028
2204
  let name = "";
2029
2205
  let email = "";
2030
2206
  try {
2031
- name = git3(root, ["config", "user.name"]);
2032
- email = git3(root, ["config", "user.email"]);
2207
+ name = git4(root, ["config", "user.name"]);
2208
+ email = git4(root, ["config", "user.email"]);
2033
2209
  } catch {
2034
2210
  }
2035
2211
  if (!name || !email) return { ...base, reason: "no-identity" };
2036
2212
  try {
2037
- execFileSync5("git", ["-C", root, "diff", "--cached", "--quiet"]);
2213
+ execFileSync6("git", ["-C", root, "diff", "--cached", "--quiet"]);
2038
2214
  } catch {
2039
2215
  return { ...base, reason: "staged-changes", name, email };
2040
2216
  }
2041
2217
  let subject = "";
2042
2218
  let message = "";
2043
2219
  try {
2044
- subject = git3(root, ["show", "-s", "--format=%s", "HEAD"]);
2045
- message = git3(root, ["show", "-s", "--format=%B", "HEAD"]);
2220
+ subject = git4(root, ["show", "-s", "--format=%s", "HEAD"]);
2221
+ message = git4(root, ["show", "-s", "--format=%B", "HEAD"]);
2046
2222
  } catch {
2047
2223
  return { ...base, reason: "no-commit" };
2048
2224
  }
@@ -2059,11 +2235,84 @@ function affirmAuthorship(root, opts = {}) {
2059
2235
  added.push("Assisted-by");
2060
2236
  }
2061
2237
  if (trailers.length > 0) {
2062
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2238
+ execFileSync6("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2063
2239
  }
2064
2240
  const category = hasAssist || added.includes("Assisted-by") ? "with_ai" : "human";
2065
2241
  return { ...base, ok: true, changed: trailers.length > 0, name, email, subject, added, category };
2066
2242
  }
2243
+ function parseFor(args) {
2244
+ const i = args.findIndex((a2) => a2 === "--for" || a2.startsWith("--for="));
2245
+ if (i === -1) return void 0;
2246
+ const a = args[i];
2247
+ const v = a.startsWith("--for=") ? a.slice("--for=".length) : args[i + 1];
2248
+ return v && !v.startsWith("-") ? v : void 0;
2249
+ }
2250
+ function discloseFor(root, target, opts) {
2251
+ const none = { disclosed: [], skipped: [], lines: [] };
2252
+ let email = "";
2253
+ try {
2254
+ email = git4(root, ["config", "user.email"]).toLowerCase();
2255
+ } catch {
2256
+ }
2257
+ if (!email) return { ok: false, reason: "no-identity", ...none };
2258
+ try {
2259
+ execFileSync6("git", ["-C", root, "diff", "--cached", "--quiet"]);
2260
+ } catch {
2261
+ return { ok: false, reason: "staged-changes", ...none };
2262
+ }
2263
+ let commits;
2264
+ if (target.includes("..")) {
2265
+ commits = [...readGitLog(root, void 0, target), ...readAuthoredMerges(root, { range: target })];
2266
+ } else {
2267
+ try {
2268
+ const sha = git4(root, ["rev-parse", "--verify", "--quiet", `${target}^{commit}`]);
2269
+ commits = readGitLog(root, 1, sha);
2270
+ if (commits[0]?.sha !== sha) commits = readAuthoredMerges(root, { range: `${sha}^!` });
2271
+ } catch {
2272
+ return { ok: false, reason: "bad-target", ...none };
2273
+ }
2274
+ }
2275
+ if (commits.length === 0) return { ok: false, reason: "bad-target", ...none };
2276
+ const later = readDisclosureCommits(root);
2277
+ const disclosed = [];
2278
+ const skipped = [];
2279
+ for (const c of commits) {
2280
+ const sha = (c.sha ?? "").toLowerCase();
2281
+ const subject2 = c.message.split("\n")[0] ?? "";
2282
+ if (later.disclosureCommits.has(sha)) continue;
2283
+ if ((c.authorEmail ?? "").toLowerCase() !== email) skipped.push({ sha, subject: subject2, reason: "not-yours" });
2284
+ else if (hasAuthorshipDisclosure(c.message)) skipped.push({ sha, subject: subject2, reason: "already-discloses" });
2285
+ else if (later.applied.has(sha)) skipped.push({ sha, subject: subject2, reason: "already-disclosed-later" });
2286
+ else disclosed.push({ sha, subject: subject2 });
2287
+ }
2288
+ const t = disclosureTrailers(root, { category: opts.category, tool: opts.tool, model: opts.model, env: {}, home: "/nonexistent" });
2289
+ if (!t.ok) return { ok: false, reason: "no-identity", ...none };
2290
+ if (disclosed.length === 0) return { ok: true, disclosed, skipped, lines: t.lines };
2291
+ const n = disclosed.length;
2292
+ const subject = `Disclose authorship of ${n} earlier commit${n === 1 ? "" : "s"}`;
2293
+ const body = [...disclosed.map((d) => `Discloses: ${d.sha}`), ...t.lines].join("\n");
2294
+ execFileSync6("git", ["-C", root, "commit", "--allow-empty", "-m", subject, "-m", body], { stdio: ["ignore", "ignore", "pipe"] });
2295
+ return { ok: true, commit: git4(root, ["rev-parse", "HEAD"]), disclosed, skipped, lines: t.lines };
2296
+ }
2297
+ function renderDiscloseFor(cmd2, target, r) {
2298
+ if (!r.ok) {
2299
+ if (r.reason === "no-identity") console.error(`madeby ${cmd2}: set your git identity first (git config user.name / user.email).`);
2300
+ else if (r.reason === "staged-changes") console.error(`madeby ${cmd2}: you have staged changes. A disclosure commit carries no content \u2014 commit or unstage them first.`);
2301
+ else console.error(`madeby ${cmd2}: no commits found for ${target}. Give a commit or a range, e.g. --for origin/dev..HEAD`);
2302
+ process.exit(1);
2303
+ return;
2304
+ }
2305
+ const why = { "not-yours": "another author's \u2014 they disclose their own", "already-discloses": "its message already discloses", "already-disclosed-later": "a disclosure commit already covers it" };
2306
+ for (const s of r.skipped) console.log(` skipped ${s.sha.slice(0, 8)} ${s.subject.slice(0, 50)} (${why[s.reason]})`);
2307
+ if (!r.commit) {
2308
+ console.log(`madeby ${cmd2}: nothing to disclose in ${target}.`);
2309
+ return;
2310
+ }
2311
+ console.log(
2312
+ `madeby ${cmd2}: made disclosure commit ${r.commit.slice(0, 8)} for ${r.disclosed.length} earlier commit${r.disclosed.length === 1 ? "" : "s"} (${r.lines.join("; ")}).
2313
+ Nothing was rewritten. Push it as usual; on a pull request the check re-runs.`
2314
+ );
2315
+ }
2067
2316
  function meCommand(args) {
2068
2317
  const { withAi, tool } = parseWithAi(args);
2069
2318
  const model = parseModel(args);
@@ -2078,6 +2327,11 @@ function meCommand(args) {
2078
2327
  process.exit(2);
2079
2328
  return;
2080
2329
  }
2330
+ const forTarget = parseFor(args);
2331
+ if (forTarget) {
2332
+ renderDiscloseFor("me", forTarget, discloseFor(root, forTarget, { category: withAi ? "with_ai" : "human", tool, model }));
2333
+ return;
2334
+ }
2081
2335
  if (args.includes("--print")) {
2082
2336
  const t = disclosureTrailers(root, { category: withAi ? "with_ai" : "human", tool, model });
2083
2337
  if (!t.ok) {
@@ -2120,7 +2374,7 @@ function aiCommand(args) {
2120
2374
  const makeSample = args.includes("--make-parser-sample");
2121
2375
  const witness = args.includes("--witness");
2122
2376
  const model = parseModel(args);
2123
- const positional = args.filter((a, i) => !a.startsWith("--") && !(args[i - 1] === "--model"));
2377
+ const positional = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--model" && args[i - 1] !== "--for");
2124
2378
  if (makeSample) {
2125
2379
  const s = writeParserSample(root, { transcriptPath: positional[0] });
2126
2380
  if (s.error === "transcript-not-found") {
@@ -2162,6 +2416,11 @@ function aiCommand(args) {
2162
2416
  return;
2163
2417
  }
2164
2418
  const tool = positional[0] || "AI";
2419
+ const forTarget = parseFor(args);
2420
+ if (forTarget) {
2421
+ renderDiscloseFor("ai", forTarget, discloseFor(root, forTarget, { category: "ai", tool, model }));
2422
+ return;
2423
+ }
2165
2424
  if (args.includes("--print")) {
2166
2425
  console.log(disclosureTrailers(root, { category: "ai", tool, model }).lines.join("\n"));
2167
2426
  return;
@@ -2250,6 +2509,22 @@ function policyIsRequired(root) {
2250
2509
  var AGENT_ROWS = 8;
2251
2510
  var pct = (n, d) => d > 0 ? Math.round(n / d * 100) : 0;
2252
2511
  var bar = (p) => "\u2588".repeat(Math.round(p / 5)) + "\u2591".repeat(20 - Math.round(p / 5));
2512
+ function renderByAuthor(r) {
2513
+ console.log(`authorship coverage by author \u2014 ${r.scope} (estimate \xB7 disclosure, not detection)`);
2514
+ if (r.byAuthor.length === 0) {
2515
+ console.log(" (no commits to score)");
2516
+ return;
2517
+ }
2518
+ const label = (a) => a.name && a.name !== a.email ? `${a.name} <${a.email}>` : a.email;
2519
+ const width = Math.max(...r.byAuthor.map((a) => label(a).length));
2520
+ console.log(` ${"author".padEnd(width)} commits disclosed human with_ai ai`);
2521
+ for (const a of r.byAuthor) {
2522
+ const k = a.byCategory;
2523
+ console.log(
2524
+ ` ${label(a).padEnd(width)} ${String(a.commits).padStart(7)} ${`${a.known} \xB7 ${pct(a.known, a.commits)}%`.padStart(9)} ${String(k.human).padStart(6)} ${String(k.with_ai).padStart(7)} ${String(k.ai + k.bot).padStart(5)}`
2525
+ );
2526
+ }
2527
+ }
2253
2528
  function render(r, required) {
2254
2529
  const knownPct = pct(r.known, r.total);
2255
2530
  const unknownPct = 100 - knownPct;
@@ -2288,7 +2563,14 @@ function render(r, required) {
2288
2563
  }
2289
2564
  function coverageCommand(args) {
2290
2565
  const json = args.includes("--json");
2291
- const range = args.find((a) => !a.startsWith("--"));
2566
+ const byAt = args.findIndex((a) => a === "--by" || a.startsWith("--by="));
2567
+ const by = byAt < 0 ? void 0 : args[byAt].includes("=") ? args[byAt].split("=")[1] : args[byAt + 1];
2568
+ if (by !== void 0 && by !== "author") {
2569
+ console.error("madeby coverage: --by takes 'author'.");
2570
+ process.exit(2);
2571
+ return;
2572
+ }
2573
+ const range = args.find((a, i) => !a.startsWith("--") && !(byAt >= 0 && i === byAt + 1 && !args[byAt].includes("=")));
2292
2574
  const root = repoRoot();
2293
2575
  if (!root) {
2294
2576
  console.error("madeby coverage: not a git repository.");
@@ -2300,12 +2582,18 @@ function coverageCommand(args) {
2300
2582
  console.log(JSON.stringify(r, null, 2));
2301
2583
  return;
2302
2584
  }
2303
- render(r, policyIsRequired(root));
2585
+ if (by === "author") renderByAuthor(r);
2586
+ else render(r, policyIsRequired(root));
2304
2587
  }
2305
2588
 
2306
2589
  // src/init-repo.ts
2307
2590
  import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync2 } from "node:fs";
2308
2591
  import { dirname as dirname2, join as join8 } from "node:path";
2592
+
2593
+ // src/version.ts
2594
+ var VERSION = "0.3.14";
2595
+
2596
+ // src/init-repo.ts
2309
2597
  var ACO_VERSION = 2;
2310
2598
  var ACO_OPEN = `<!-- madeby:aco v${ACO_VERSION} -->`;
2311
2599
  var ACO_CLOSE = `<!-- /madeby:aco -->`;
@@ -2451,6 +2739,8 @@ function initRepo(root, opts = {}) {
2451
2739
  ensureAco(join8(root, "CLAUDE.md"), "CLAUDE.md", res, { createIfAbsent: false, ...aco });
2452
2740
  if (host === "github") {
2453
2741
  writeIfAbsent(join8(root, ".github", "workflows", "disclosure.yml"), ".github/workflows/disclosure.yml", GH_WORKFLOW, created, skipped);
2742
+ } else if (host === "azure-devops") {
2743
+ writeIfAbsent(join8(root, ".madeby", "azure-pipelines.yml"), ".madeby/azure-pipelines.yml", AZURE_PIPELINE, created, skipped);
2454
2744
  }
2455
2745
  const nextSteps = ["Commit the created files."];
2456
2746
  if (raised.length > 0) {
@@ -2475,19 +2765,21 @@ function initRepo(root, opts = {}) {
2475
2765
  nextSteps.push(`${outdated.join(" + ")} carries an OUTDATED ACO convention \u2014 run 'madeby init --update' to refresh it in place (only the ACO block changes).`);
2476
2766
  }
2477
2767
  if (host === "azure-devops") {
2478
- nextSteps.push("Add the Azure Pipelines template (packages/action/azure-pipelines-disclosure.yml) and wire it as a PR build-validation branch policy.");
2768
+ nextSteps.push("In Azure DevOps, create a pipeline from .madeby/azure-pipelines.yml (a PR-only pipeline: no credentials kept, madeby pinned) and add it to the protected branch as a build-validation policy.");
2479
2769
  } else if (host !== "github") {
2480
2770
  nextSteps.push(`Add a CI step that runs 'npx madeby check' on pull requests for ${host}.`);
2481
2771
  }
2482
2772
  if (policy.mode !== "required") {
2483
2773
  nextSteps.push(`When ready to enforce: run 'madeby init --required' (or set "mode": "required" in .madeby/policy.json), then make the check required on the protected branch \u2014 the host's control plane (GitHub: gh api branch protection; Azure: a build-validation policy). See the MCP resource madeby://guide/enforce for exact commands.`);
2484
2774
  }
2485
- nextSteps.push("Contributors disclose origin with a Co-Authored-By / Generated-by trailer (AI), an Authored-by-human trailer ('madeby me'), an Authored-by-ai trailer ('madeby ai' \u2014 AI authored, committer accountable), a DCO Signed-off-by, or a signed commit. Evidence upgrade: 'madeby ai --witness' records your own AI session's witnessed spans.");
2775
+ nextSteps.push("Contributors disclose origin with a Co-Authored-By / Generated-by trailer (AI), an Authored-by-human trailer ('madeby me'), or an Authored-by-ai trailer ('madeby ai' \u2014 AI authored, committer accountable). A DCO Signed-off-by or a signed commit alone doesn't say who wrote the code, so it counts only if the policy's 'accept' lists it. Evidence upgrade: 'madeby ai --witness' records your own AI session's witnessed spans.");
2486
2776
  nextSteps.push("Existing history is accounted for honestly, not fabricated: run 'madeby check' over full history to recognize what's already disclosed; pre-adoption commits stay unknown. See madeby://guide/backfill.");
2487
2777
  return { created, skipped, merged, updated, outdated, raised, modelOn, policyMode: policy.mode, recordModel: policy.recordModel, host, nextSteps };
2488
2778
  }
2489
2779
  var GH_WORKFLOW = `name: Disclosure
2490
2780
  on: pull_request
2781
+ permissions:
2782
+ contents: read
2491
2783
  jobs:
2492
2784
  madeby:
2493
2785
  runs-on: ubuntu-latest
@@ -2495,7 +2787,27 @@ jobs:
2495
2787
  - uses: actions/checkout@v4
2496
2788
  with:
2497
2789
  fetch-depth: 0
2790
+ persist-credentials: false
2498
2791
  - uses: MadeByFYI/MadeBy/packages/action@v0.2.0
2792
+ with:
2793
+ version: "${VERSION}"
2794
+ `;
2795
+ var AZURE_PIPELINE = `# MadeBy disclosure check. Run it as a build-validation policy on the protected branch: Azure
2796
+ # DevOps runs it for each pull request. It reads the git history only, so it keeps no credentials.
2797
+ trigger: none
2798
+
2799
+ pool:
2800
+ vmImage: ubuntu-latest
2801
+
2802
+ steps:
2803
+ - checkout: self
2804
+ fetchDepth: 0 # the PR's commits and the target branch, so the range can be read
2805
+ persistCredentials: false
2806
+ - task: NodeTool@0
2807
+ inputs:
2808
+ versionSpec: "20.x"
2809
+ - script: npx --yes madeby@${VERSION} check
2810
+ displayName: MadeBy disclosure check
2499
2811
  `;
2500
2812
 
2501
2813
  // src/commands/init.ts
@@ -2526,13 +2838,13 @@ madeby init \u2014 policy: ${r.policyMode}${r.recordModel ? " + records model" :
2526
2838
  }
2527
2839
 
2528
2840
  // src/mcp.ts
2529
- import { execFileSync as execFileSync6 } from "node:child_process";
2841
+ import { execFileSync as execFileSync7 } from "node:child_process";
2530
2842
  import { createInterface } from "node:readline";
2531
2843
  var DEFAULT_PROTOCOL = "2024-11-05";
2532
- var SERVER_INFO = { name: "madeby", version: "0.3.12" };
2844
+ var SERVER_INFO = { name: "madeby", version: VERSION };
2533
2845
  function rootFor(path) {
2534
2846
  const cwd = path && path.length ? path : process.cwd();
2535
- return execFileSync6("git", ["-C", cwd, "-c", "core.quotePath=false", "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
2847
+ return execFileSync7("git", ["-C", cwd, "-c", "core.quotePath=false", "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
2536
2848
  }
2537
2849
  var repoInput = {
2538
2850
  type: "object",
@@ -2570,7 +2882,7 @@ var TOOLS = [
2570
2882
  },
2571
2883
  {
2572
2884
  name: "coverage",
2573
- description: "Authorship coverage scoreboard for a repo \u2014 the honest estimate of how much of the code's authorship is ON THE RECORD (known) vs undisclosed (unknown \u2014 origin unknown, NEVER guessed), the human/with_ai/ai mix OF THE KNOWN (never of the whole repo), the asserted-vs-verified trust split, and byAgent: the agents and model versions named in the AI commits, exactly as disclosed (a commit naming a tool and its model counts under both). Disclosure, not detection; the report is a labeled estimate (counts only \u2014 derive percents at the edge). Use it to show a maintainer their provenance picture and motivate requiring disclosure.",
2885
+ description: "Authorship coverage scoreboard for a repo \u2014 the honest estimate of how much of the code's authorship is ON THE RECORD (known) vs undisclosed (unknown \u2014 origin unknown, NEVER guessed), the human/with_ai/ai mix OF THE KNOWN (never of the whole repo), the asserted-vs-verified trust split, byAgent: the agents and model versions named in the AI commits, exactly as disclosed (a commit naming a tool and its model counts under both), and byAuthor: the same picture per commit author (by email), to see who discloses and who doesn't. Disclosure, not detection; the report is a labeled estimate (counts only \u2014 derive percents at the edge). Use it to show a maintainer their provenance picture and motivate requiring disclosure.",
2574
2886
  inputSchema: repoInput,
2575
2887
  handler: (args) => repoCoverage(rootFor(args.path), { range: args.range })
2576
2888
  },
@@ -2595,7 +2907,7 @@ var TOOLS = [
2595
2907
  },
2596
2908
  {
2597
2909
  name: "disclose",
2598
- description: "Disclose authorship on HEAD per ACO \u2014 the agent-native disclosure of work you just did. Pick the honest category: 'ai' (an AI authored it; you, the committer, are accountable) adds Authored-by-ai; 'with_ai' (a human authored it WITH AI assistance) adds Authored-by-human + Assisted-by; 'human' adds Authored-by-human. IMPORTANT: if the AI wrote the code and a human is merely committing it, that is 'ai' \u2014 do NOT claim 'with_ai'/human authorship the human didn't do. Amends HEAD's MESSAGE only (a testimony, never a content change) and refuses if there are staged changes. Evidence upgrade for the ai category (witnessed spans from a session log) is the CLI `madeby ai --witness`. Read the standard: madeby://guide/aco.",
2910
+ description: "Disclose authorship on HEAD per ACO \u2014 the agent-native disclosure of work you just did. Pick the honest category: 'ai' (an AI authored it; you, the committer, are accountable) adds Authored-by-ai; 'with_ai' (a human authored it WITH AI assistance) adds Authored-by-human + Assisted-by; 'human' adds Authored-by-human. IMPORTANT: if the AI wrote the code and a human is merely committing it, that is 'ai' \u2014 do NOT claim 'with_ai'/human authorship the human didn't do. Amends HEAD's MESSAGE only (a testimony, never a content change) and refuses if there are staged changes. Evidence upgrade for the ai category (witnessed spans from a session log) is the CLI `madeby ai --witness`. To disclose EARLIER commits without rewriting them (a repo that forbids amend/rebase), pass `for` (a commit or range): it makes one empty disclosure commit naming them (Discloses: <hash>), only for the git user's own undisclosed commits; push it and the PR check re-runs. Read the standard: madeby://guide/aco.",
2599
2911
  inputSchema: {
2600
2912
  type: "object",
2601
2913
  properties: {
@@ -2604,7 +2916,8 @@ var TOOLS = [
2604
2916
  with_ai: { type: "boolean", description: "legacy shorthand: true \u21D2 category with_ai, false \u21D2 human. Prefer `category`." },
2605
2917
  tool: { type: "string", description: "the AI tool to name (Assisted-by for with_ai, Authored-by-ai for ai). Default: AI." },
2606
2918
  print: { type: "boolean", description: "return the disclosure trailer lines instead of amending HEAD \u2014 for platforms that make the commit themselves; put them in the commit message's last paragraph" },
2607
- model: { type: "string", description: "the model version you are running as, e.g. claude-sonnet-5 \u2014 written as tool:model. Give it when the repo's AGENTS.md asks; omit it rather than guess." }
2919
+ model: { type: "string", description: "the model version you are running as, e.g. claude-sonnet-5 \u2014 written as tool:model. Give it when the repo's AGENTS.md asks; omit it rather than guess." },
2920
+ for: { type: "string", description: "disclose earlier commits instead of HEAD: a commit or a range (e.g. origin/dev..HEAD). Makes an empty disclosure commit; nothing is rewritten. The model is named only if you pass `model` for those commits." }
2608
2921
  }
2609
2922
  },
2610
2923
  handler: (args) => {
@@ -2612,6 +2925,10 @@ var TOOLS = [
2612
2925
  const tool = args.tool;
2613
2926
  const model = args.model;
2614
2927
  const category = args.category;
2928
+ if (typeof args.for === "string" && args.for) {
2929
+ const c = category === "ai" ? "ai" : category === "with_ai" || args.with_ai === true ? "with_ai" : "human";
2930
+ return discloseFor(root, args.for, { category: c, tool, model: c === "human" ? void 0 : model });
2931
+ }
2615
2932
  if (args.print === true) {
2616
2933
  const c = category === "ai" ? "ai" : category === "with_ai" || args.with_ai === true ? "with_ai" : "human";
2617
2934
  return disclosureTrailers(root, { category: c, tool, model });
@@ -2651,8 +2968,9 @@ guesses whether code is AI. To align a repo:
2651
2968
  3. **Disclose going forward** \u2014 contributors disclose origin with an \`Authored-by-ai\` trailer (an AI
2652
2969
  wrote it; \`madeby ai\` adds it), \`Authored-by-human\` plus \`Assisted-by\` (a human wrote it with AI;
2653
2970
  \`madeby me --with-ai\`), \`Authored-by-human\` alone (\`madeby me\`), a Co-Authored-By / Generated-by AI
2654
- trailer, a DCO Signed-off-by, a signed commit, or witnessed spans from an AI session log
2655
- (\`madeby ai --witness\`). Human and AI disclosure ride the same ladder.
2971
+ trailer, or witnessed spans from an AI session log (\`madeby ai --witness\`). Human and AI disclosure
2972
+ ride the same ladder. A DCO Signed-off-by or a signed commit alone says who is accountable, not who
2973
+ wrote the code, so it satisfies the policy only if the policy's \`accept\` lists it.
2656
2974
  4. **Account for existing history honestly** \u2014 do NOT fabricate disclosure for past commits. Read the
2657
2975
  \`madeby://guide/backfill\` resource: recognize what's already disclosed, attach recoverable
2658
2976
  evidence, and set an adoption boundary \u2014 pre-boundary history is labeled *unknown*, never invented.
@@ -2690,8 +3008,8 @@ from a PR.
2690
3008
  ## Azure DevOps (build validation branch policy)
2691
3009
 
2692
3010
  Project Settings -> Repositories -> (repo) -> Policies -> Branch (main) -> Build Validation -> Add:
2693
- point it at the pipeline that includes azure-pipelines-disclosure.yml; set trigger automatic and
2694
- required. CLI equivalent: az repos policy build create ... --branch main --blocking true.
3011
+ point it at the pipeline created from .madeby/azure-pipelines.yml (written by init with host
3012
+ azure-devops); set trigger automatic and required. CLI equivalent: az repos policy build create ... --branch main --blocking true.
2695
3013
  `;
2696
3014
  var BACKFILL_GUIDE = `# Honest backfill for an existing repo
2697
3015
 
@@ -2700,8 +3018,9 @@ refuses it. But you CAN make an honest, verifiable statement about history. Back
2700
3018
  never raise a commit's origin above its evidence \u2014 unknown stays unknown, just explicitly so.
2701
3019
 
2702
3020
  1. **Recognize what's already there** (free, pure evidence). Run \`check\` over FULL history:
2703
- existing Co-Authored-By AI trailers, DCO Signed-off-by, signed commits, and bot authorship are
2704
- recognized now even though they predate adoption. Much of your history may already be disclosed.
3021
+ existing Co-Authored-By AI trailers and bot authorship are recognized now even though they predate
3022
+ adoption. Much of your history may already be disclosed. (DCO sign-offs and signatures are
3023
+ recognized too, but say who is accountable, not who wrote the code.)
2705
3024
 
2706
3025
  2. **Attach recoverable evidence** (opt-in, evidence-grade):
2707
3026
  - Old AI-tool session logs -> \`ai\` attaches structurally-verified spans to their commits (it
@@ -2732,7 +3051,7 @@ var POLICY_SCHEMA = {
2732
3051
  properties: {
2733
3052
  version: { const: 0 },
2734
3053
  mode: { enum: ["advisory", "required"], description: "advisory: report only (the default, and the safe degrade); required: fail on any undisclosed commit" },
2735
- accept: { type: "array", items: { type: "string" }, description: "restrict what satisfies the policy (e.g. dco-signoff, ai-trailer, commit-signature); omit \u21D2 any recognized disclosure counts" },
3054
+ accept: { type: "array", items: { type: "string" }, description: "restrict what satisfies the policy (e.g. dco-signoff, ai-trailer, commit-signature); omit \u21D2 any authorship disclosure counts (a DCO sign-off or signature alone counts only if listed)" },
2736
3055
  recordModel: { type: "boolean", description: "ask AI disclosures to name the model version too (tool:model, e.g. Assisted-by: Cursor:gpt-5.3-codex); madeby reads it from the tool's own session record where it can. Set by init --record-model. A request, not a gate" }
2737
3056
  }
2738
3057
  };
@@ -2865,7 +3184,7 @@ Usage:
2865
3184
  (alias: whom) file's origin, or the whole repo with no path. --lines=A-B to scope.
2866
3185
  madeby coverage [<range>] The scoreboard: how much of your repo's authorship is disclosed (known)
2867
3186
  vs unknown, the human/with_ai/ai mix, and the asserted/verified split.
2868
- --json for a machine-readable result.
3187
+ --by author for one row per commit author. --json for a machine-readable result.
2869
3188
  madeby ai [<log>] [<ref>] Made by AI: record your AI session's witnessed spans into .madeby/spans.
2870
3189
  <log> defaults to the auto-discovered Claude Code transcript.
2871
3190
  madeby me Made by me: affirm you authored HEAD yourself (an Authored-by-human
@@ -2875,6 +3194,8 @@ Usage:
2875
3194
  --model <version> names the model too (Assisted-by: <tool>:<version>).
2876
3195
  --print (me / ai) prints the trailer lines instead of amending HEAD,
2877
3196
  for the commit message itself: git commit -m <msg> -m "$(madeby ai <tool> --print)".
3197
+ --for <commit|range> (me / ai) discloses EARLIER commits instead, with one
3198
+ new empty commit naming them (Discloses: <hash>) \u2014 nothing rewritten.
2878
3199
  madeby mcp Run the MCP server (stdio) \u2014 the tools, agent-callable.
2879
3200
  madeby help Show this help.
2880
3201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "madeby",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "mcpName": "io.github.MadeByFYI/madeby",
5
5
  "description": "Disclose AI authorship of code. `npx madeby ai` adds an Authored-by-ai git trailer; AI coding agents disclose per the ACO standard (human/with_ai/ai) from an AGENTS.md convention. Disclosure, not detection. Gate PRs with `madeby check`.",
6
6
  "license": "MIT",