madeby 0.3.13 → 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 +19 -0
  2. package/dist/madeby.mjs +232 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -148,6 +148,25 @@ of AI disclosure); a sworn declaration is *sworn*. The one asymmetry is evidence
148
148
  leave a re-checkable artifact (a session log, `madeby ai`); human authorship is your firsthand
149
149
  testimony. If you used AI, disclose the AI — don't claim human.
150
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
+
151
170
  **The policy file is optional.** With no `.madeby/policy.json`, `check` defaults to **advisory** —
152
171
  it reports coverage and never blocks, so you can run it (or add the CI check) with zero config. Add
153
172
  the file only when you want to enforce:
package/dist/madeby.mjs CHANGED
@@ -17,19 +17,19 @@ var parseMeta = (rec) => {
17
17
  };
18
18
  };
19
19
  function historyProblem(repoPath, range) {
20
- const git4 = (...args) => {
20
+ const git5 = (...args) => {
21
21
  try {
22
22
  return execFileSync("git", ["-C", repoPath, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
23
23
  } catch {
24
24
  return null;
25
25
  }
26
26
  };
27
- if (git4("rev-parse", "--is-shallow-repository") === "true") {
27
+ if (git5("rev-parse", "--is-shallow-repository") === "true") {
28
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
29
  }
30
30
  if (range) {
31
31
  for (const end of range.split(/\.{2,3}/).filter(Boolean)) {
32
- if (git4("rev-parse", "--verify", "--quiet", `${end}^{commit}`) === null) {
32
+ if (git5("rev-parse", "--verify", "--quiet", `${end}^{commit}`) === null) {
33
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
34
  }
35
35
  }
@@ -971,6 +971,16 @@ function commitDisclosureKinds(commit) {
971
971
  if (commit.signed === true) kinds.push("commit-signature");
972
972
  return kinds;
973
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
+ }
974
984
 
975
985
  // ../core/src/policy.ts
976
986
  var DEFAULT_POLICY = { version: 0, mode: "advisory" };
@@ -1070,13 +1080,85 @@ registerHostAdapter(githubAdapter);
1070
1080
  // ../analyzer/src/read-disclosures.ts
1071
1081
  import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1072
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
1073
1147
  function readDisclosures(root, opts = {}) {
1074
- const commits = readGitLog(root, opts.range ? void 0 : opts.limit ?? 200, opts.range);
1075
- return commits.map((c) => ({
1076
- sha: c.sha ?? "",
1077
- subject: c.message.split("\n")[0] ?? "",
1078
- disclosures: commitDisclosureKinds({ message: c.message, signed: c.signed })
1079
- }));
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
+ };
1080
1162
  }
1081
1163
  function evaluateRepoDisclosure(root, opts = {}) {
1082
1164
  let policy = DEFAULT_POLICY;
@@ -1089,7 +1171,7 @@ function evaluateRepoDisclosure(root, opts = {}) {
1089
1171
  warning = ".madeby/policy.json is not valid JSON \u2014 degrading to advisory (report-only, never blocks).";
1090
1172
  }
1091
1173
  }
1092
- const commits = readDisclosures(root, opts);
1174
+ const { commits, rejected } = readDisclosuresWithRejects(root, opts);
1093
1175
  const disclosures = commits.map((c) => ({ ref: (c.sha || "").slice(0, 8) || "(unknown)", kinds: c.disclosures }));
1094
1176
  const result = evaluateDisclosurePolicy(policy, disclosures);
1095
1177
  const subjectFor = (ref) => commits.find((c) => (c.sha || "").startsWith(ref))?.subject ?? "";
@@ -1099,12 +1181,13 @@ function evaluateRepoDisclosure(root, opts = {}) {
1099
1181
  summary: result.summary,
1100
1182
  total: disclosures.length,
1101
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)),
1102
1185
  ...warning ? { warning } : {}
1103
1186
  };
1104
1187
  }
1105
1188
 
1106
1189
  // ../analyzer/src/path-provenance.ts
1107
- import { execFileSync as execFileSync2 } from "node:child_process";
1190
+ import { execFileSync as execFileSync3 } from "node:child_process";
1108
1191
  import { readFileSync as readFileSync4, existsSync as existsSync2 } from "node:fs";
1109
1192
  import { join as join3 } from "node:path";
1110
1193
 
@@ -1141,13 +1224,13 @@ function readSpanManifestsFromDir(repoDir) {
1141
1224
 
1142
1225
  // ../analyzer/src/path-provenance.ts
1143
1226
  var overlaps = (aS, aE, bS, bE) => bS === void 0 || bE === void 0 || aS <= bE && aE >= bS;
1144
- function git(root, args) {
1145
- 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"] });
1146
1229
  }
1147
1230
  var US = "";
1148
1231
  function commitMeta(root, sha) {
1149
1232
  try {
1150
- 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]);
1151
1234
  const parts = out.split(US);
1152
1235
  const sig = parts[0] ?? "N";
1153
1236
  return { signed: sig !== "N" && sig !== "", name: parts[1] ?? "", email: parts[2] ?? "", message: (parts.slice(3).join(US) ?? "").trim() };
@@ -1217,12 +1300,17 @@ function provenanceOf(root, path, opts = {}) {
1217
1300
  if (opts.useBlame !== false) {
1218
1301
  try {
1219
1302
  const range = opts.startLine !== void 0 ? [`-L`, `${opts.startLine},${opts.endLine ?? opts.startLine}`] : [];
1220
- const porcelain = git(root, ["blame", "--porcelain", ...range, "--", path]);
1303
+ const porcelain = git2(root, ["blame", "--porcelain", ...range, "--", path]);
1221
1304
  const metaCache = /* @__PURE__ */ new Map();
1305
+ const late = readDisclosureCommits(root);
1222
1306
  for (const g of blameGroups(porcelain)) {
1223
1307
  let meta = metaCache.get(g.sha);
1224
1308
  if (meta === void 0) {
1225
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}` };
1226
1314
  metaCache.set(g.sha, meta);
1227
1315
  }
1228
1316
  if (!meta) continue;
@@ -1273,7 +1361,7 @@ function provenanceMap(root, opts = {}) {
1273
1361
  const witnessed = [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
1274
1362
  let trackedFiles = 0;
1275
1363
  try {
1276
- 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;
1277
1365
  } catch {
1278
1366
  }
1279
1367
  const commits = readDisclosures(root, { limit: opts.commitLimit ?? 500 });
@@ -1313,10 +1401,11 @@ function repoCoverage(root, opts = {}) {
1313
1401
  const oldest = authored.at(-1)?.sha;
1314
1402
  const since = limit && oldest && authored.length >= limit ? commitTime(root, oldest) : void 0;
1315
1403
  const merges = authored.length > 0 && (!limit || authored.length < limit || since !== void 0) ? readAuthoredMerges(root, { range: opts.range, since }) : [];
1316
- const commits = [...authored, ...merges];
1404
+ const commits = applyLateDisclosures([...authored, ...merges], readDisclosureCommits(root, rangeHead(opts.range)));
1317
1405
  const byCategory = { human: 0, with_ai: 0, ai: 0, bot: 0 };
1318
1406
  const byTier = { asserted: 0, verified: 0 };
1319
1407
  let known = 0;
1408
+ let late = 0;
1320
1409
  const agents = /* @__PURE__ */ new Map();
1321
1410
  const authors = /* @__PURE__ */ new Map();
1322
1411
  for (const c of commits) {
@@ -1331,6 +1420,7 @@ function repoCoverage(root, opts = {}) {
1331
1420
  author.known += 1;
1332
1421
  author.byCategory[category] += 1;
1333
1422
  known += 1;
1423
+ if (c.late) late += 1;
1334
1424
  byCategory[category] += 1;
1335
1425
  if (c.signed) byTier.verified += 1;
1336
1426
  else byTier.asserted += 1;
@@ -1350,11 +1440,12 @@ function repoCoverage(root, opts = {}) {
1350
1440
  }
1351
1441
  return {
1352
1442
  isEstimate: true,
1353
- 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",
1354
1444
  scope: (opts.range ? `range ${opts.range}` : `last ${authored.length} commits`) + (merges.length ? ` + ${merges.length} merges with changes of their own` : ""),
1355
1445
  total: commits.length,
1356
1446
  known,
1357
1447
  unknown: commits.length - known,
1448
+ late,
1358
1449
  byCategory,
1359
1450
  byTier,
1360
1451
  byAgent: [...agents.values()].sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name)),
@@ -1363,7 +1454,7 @@ function repoCoverage(root, opts = {}) {
1363
1454
  }
1364
1455
 
1365
1456
  // ../analyzer/src/record-spans.ts
1366
- import { execFileSync as execFileSync3 } from "node:child_process";
1457
+ import { execFileSync as execFileSync4 } from "node:child_process";
1367
1458
  import { existsSync as existsSync3, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync5, statSync, writeFileSync } from "node:fs";
1368
1459
  import { homedir } from "node:os";
1369
1460
  import { dirname, join as join4 } from "node:path";
@@ -1696,8 +1787,8 @@ ${body}
1696
1787
  }
1697
1788
 
1698
1789
  // ../analyzer/src/record-spans.ts
1699
- function git2(root, ...a) {
1700
- 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();
1701
1792
  }
1702
1793
  function autodiscover(root) {
1703
1794
  const enc3 = root.replace(/[/_]/g, "-");
@@ -1713,7 +1804,7 @@ function recordAiSpans(root, opts = {}) {
1713
1804
  if (!transcriptPath || !existsSync3(transcriptPath)) {
1714
1805
  return { written: false, matched: 0, discarded: 0, message: "transcript not found \u2014 pass a transcript path", error: "transcript-not-found" };
1715
1806
  }
1716
- const commit = git2(root, "rev-parse", opts.ref ?? "HEAD");
1807
+ const commit = git3(root, "rev-parse", opts.ref ?? "HEAD");
1717
1808
  const transcriptText = readFileSync5(transcriptPath, "utf8");
1718
1809
  const parser = detectParser(transcriptText);
1719
1810
  if (!parser) {
@@ -1731,7 +1822,7 @@ function recordAiSpans(root, opts = {}) {
1731
1822
  }
1732
1823
  },
1733
1824
  commit,
1734
- operatorId: git2(root, "show", "-s", "--format=%ae", commit),
1825
+ operatorId: git3(root, "show", "-s", "--format=%ae", commit),
1735
1826
  now: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
1736
1827
  });
1737
1828
  if (result.manifest.attestations.length === 0) {
@@ -1850,10 +1941,10 @@ var gitlabAdapter = {
1850
1941
  registerHostAdapter(gitlabAdapter);
1851
1942
 
1852
1943
  // src/scope.ts
1853
- import { execFileSync as execFileSync4 } from "node:child_process";
1944
+ import { execFileSync as execFileSync5 } from "node:child_process";
1854
1945
  function repoRoot() {
1855
1946
  try {
1856
- 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();
1857
1948
  } catch {
1858
1949
  return null;
1859
1950
  }
@@ -1905,16 +1996,20 @@ Undisclosed commits (${result.undisclosed.length}):`);
1905
1996
  console.log(` \u2717 ${v.ref} ${v.subject.slice(0, 60)}`);
1906
1997
  }
1907
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
+ }
1908
2003
  console.log(
1909
2004
  `
1910
- 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. (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.)`
1911
2006
  );
1912
2007
  }
1913
2008
  process.exit(result.pass ? 0 : 1);
1914
2009
  }
1915
2010
 
1916
2011
  // src/commands/me.ts
1917
- import { execFileSync as execFileSync5 } from "node:child_process";
2012
+ import { execFileSync as execFileSync6 } from "node:child_process";
1918
2013
  import { existsSync as existsSync5, readFileSync as readFileSync7 } from "node:fs";
1919
2014
  import { homedir as homedir3 } from "node:os";
1920
2015
  import { join as join6 } from "node:path";
@@ -2033,8 +2128,8 @@ function parseWithAi(args) {
2033
2128
  const next = args[i + 1];
2034
2129
  return { withAi: true, tool: next && !next.startsWith("-") ? next : "AI" };
2035
2130
  }
2036
- function git3(root, args) {
2037
- return execFileSync5("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
2131
+ function git4(root, args) {
2132
+ return execFileSync6("git", ["-C", root, ...args], { encoding: "utf8" }).trim();
2038
2133
  }
2039
2134
  var HUMAN_ATTEST2 = /^[ \t]*(?:Authored-by-human|Human-authored(?:-by)?)[ \t]*:/im;
2040
2135
  var ASSIST = /^[ \t]*Assisted-by[ \t]*:/im;
@@ -2060,15 +2155,15 @@ function attestAiAuthorship(root, opts = {}) {
2060
2155
  const tool = ai.value;
2061
2156
  const base = { ok: false, changed: false, withAi: false, tool, model: ai.model, modelSource: ai.modelSource, added: [], category: "ai" };
2062
2157
  try {
2063
- execFileSync5("git", ["-C", root, "diff", "--cached", "--quiet"]);
2158
+ execFileSync6("git", ["-C", root, "diff", "--cached", "--quiet"]);
2064
2159
  } catch {
2065
2160
  return { ...base, reason: "staged-changes" };
2066
2161
  }
2067
2162
  let subject = "";
2068
2163
  let message = "";
2069
2164
  try {
2070
- subject = git3(root, ["show", "-s", "--format=%s", "HEAD"]);
2071
- 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"]);
2072
2167
  } catch {
2073
2168
  return { ...base, reason: "no-commit" };
2074
2169
  }
@@ -2079,7 +2174,7 @@ function attestAiAuthorship(root, opts = {}) {
2079
2174
  added.push("Authored-by-ai");
2080
2175
  }
2081
2176
  if (trailers.length > 0) {
2082
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2177
+ execFileSync6("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2083
2178
  }
2084
2179
  return { ...base, ok: true, changed: trailers.length > 0, subject, added };
2085
2180
  }
@@ -2091,8 +2186,8 @@ function disclosureTrailers(root, opts) {
2091
2186
  let name = "";
2092
2187
  let email = "";
2093
2188
  try {
2094
- name = git3(root, ["config", "user.name"]);
2095
- email = git3(root, ["config", "user.email"]);
2189
+ name = git4(root, ["config", "user.name"]);
2190
+ email = git4(root, ["config", "user.email"]);
2096
2191
  } catch {
2097
2192
  }
2098
2193
  if (!name || !email) return { ok: false, reason: "no-identity", lines: [] };
@@ -2109,21 +2204,21 @@ function affirmAuthorship(root, opts = {}) {
2109
2204
  let name = "";
2110
2205
  let email = "";
2111
2206
  try {
2112
- name = git3(root, ["config", "user.name"]);
2113
- email = git3(root, ["config", "user.email"]);
2207
+ name = git4(root, ["config", "user.name"]);
2208
+ email = git4(root, ["config", "user.email"]);
2114
2209
  } catch {
2115
2210
  }
2116
2211
  if (!name || !email) return { ...base, reason: "no-identity" };
2117
2212
  try {
2118
- execFileSync5("git", ["-C", root, "diff", "--cached", "--quiet"]);
2213
+ execFileSync6("git", ["-C", root, "diff", "--cached", "--quiet"]);
2119
2214
  } catch {
2120
2215
  return { ...base, reason: "staged-changes", name, email };
2121
2216
  }
2122
2217
  let subject = "";
2123
2218
  let message = "";
2124
2219
  try {
2125
- subject = git3(root, ["show", "-s", "--format=%s", "HEAD"]);
2126
- 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"]);
2127
2222
  } catch {
2128
2223
  return { ...base, reason: "no-commit" };
2129
2224
  }
@@ -2140,11 +2235,84 @@ function affirmAuthorship(root, opts = {}) {
2140
2235
  added.push("Assisted-by");
2141
2236
  }
2142
2237
  if (trailers.length > 0) {
2143
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2238
+ execFileSync6("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
2144
2239
  }
2145
2240
  const category = hasAssist || added.includes("Assisted-by") ? "with_ai" : "human";
2146
2241
  return { ...base, ok: true, changed: trailers.length > 0, name, email, subject, added, category };
2147
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
+ }
2148
2316
  function meCommand(args) {
2149
2317
  const { withAi, tool } = parseWithAi(args);
2150
2318
  const model = parseModel(args);
@@ -2159,6 +2327,11 @@ function meCommand(args) {
2159
2327
  process.exit(2);
2160
2328
  return;
2161
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
+ }
2162
2335
  if (args.includes("--print")) {
2163
2336
  const t = disclosureTrailers(root, { category: withAi ? "with_ai" : "human", tool, model });
2164
2337
  if (!t.ok) {
@@ -2201,7 +2374,7 @@ function aiCommand(args) {
2201
2374
  const makeSample = args.includes("--make-parser-sample");
2202
2375
  const witness = args.includes("--witness");
2203
2376
  const model = parseModel(args);
2204
- 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");
2205
2378
  if (makeSample) {
2206
2379
  const s = writeParserSample(root, { transcriptPath: positional[0] });
2207
2380
  if (s.error === "transcript-not-found") {
@@ -2243,6 +2416,11 @@ function aiCommand(args) {
2243
2416
  return;
2244
2417
  }
2245
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
+ }
2246
2424
  if (args.includes("--print")) {
2247
2425
  console.log(disclosureTrailers(root, { category: "ai", tool, model }).lines.join("\n"));
2248
2426
  return;
@@ -2413,7 +2591,7 @@ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as rea
2413
2591
  import { dirname as dirname2, join as join8 } from "node:path";
2414
2592
 
2415
2593
  // src/version.ts
2416
- var VERSION = "0.3.13";
2594
+ var VERSION = "0.3.14";
2417
2595
 
2418
2596
  // src/init-repo.ts
2419
2597
  var ACO_VERSION = 2;
@@ -2660,13 +2838,13 @@ madeby init \u2014 policy: ${r.policyMode}${r.recordModel ? " + records model" :
2660
2838
  }
2661
2839
 
2662
2840
  // src/mcp.ts
2663
- import { execFileSync as execFileSync6 } from "node:child_process";
2841
+ import { execFileSync as execFileSync7 } from "node:child_process";
2664
2842
  import { createInterface } from "node:readline";
2665
2843
  var DEFAULT_PROTOCOL = "2024-11-05";
2666
2844
  var SERVER_INFO = { name: "madeby", version: VERSION };
2667
2845
  function rootFor(path) {
2668
2846
  const cwd = path && path.length ? path : process.cwd();
2669
- 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();
2670
2848
  }
2671
2849
  var repoInput = {
2672
2850
  type: "object",
@@ -2729,7 +2907,7 @@ var TOOLS = [
2729
2907
  },
2730
2908
  {
2731
2909
  name: "disclose",
2732
- 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.",
2733
2911
  inputSchema: {
2734
2912
  type: "object",
2735
2913
  properties: {
@@ -2738,7 +2916,8 @@ var TOOLS = [
2738
2916
  with_ai: { type: "boolean", description: "legacy shorthand: true \u21D2 category with_ai, false \u21D2 human. Prefer `category`." },
2739
2917
  tool: { type: "string", description: "the AI tool to name (Assisted-by for with_ai, Authored-by-ai for ai). Default: AI." },
2740
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" },
2741
- 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." }
2742
2921
  }
2743
2922
  },
2744
2923
  handler: (args) => {
@@ -2746,6 +2925,10 @@ var TOOLS = [
2746
2925
  const tool = args.tool;
2747
2926
  const model = args.model;
2748
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
+ }
2749
2932
  if (args.print === true) {
2750
2933
  const c = category === "ai" ? "ai" : category === "with_ai" || args.with_ai === true ? "with_ai" : "human";
2751
2934
  return disclosureTrailers(root, { category: c, tool, model });
@@ -3011,6 +3194,8 @@ Usage:
3011
3194
  --model <version> names the model too (Assisted-by: <tool>:<version>).
3012
3195
  --print (me / ai) prints the trailer lines instead of amending HEAD,
3013
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.
3014
3199
  madeby mcp Run the MCP server (stdio) \u2014 the tools, agent-callable.
3015
3200
  madeby help Show this help.
3016
3201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "madeby",
3
- "version": "0.3.13",
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",