madeby 0.3.8 → 0.3.9

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 +21 -2
  2. package/dist/madeby.mjs +268 -70
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,6 +42,7 @@ npm i -g madeby
42
42
 
43
43
  ```
44
44
  madeby init Set MadeBy up: write .madeby/policy.json + the CI disclosure check.
45
+ --record-model (opt-in) also asks agents to name the model version.
45
46
  madeby check [<range>] The gate: do commits meet .madeby/policy.json? (a verdict — exit code
46
47
  gates). <range> e.g. origin/main..HEAD — a PR's own commits. --json too.
47
48
  madeby coverage [<range>] The scoreboard: how much of the repo's authorship is disclosed (known)
@@ -49,9 +50,10 @@ madeby coverage [<range>] The scoreboard: how much of the repo's authorship
49
50
  madeby who [<path>] Made by whom? What's on the record about who made this — one file's
50
51
  (alias: whom) origin, or the whole repo with no path. --lines=A-B to scope. --json too.
51
52
  madeby ai [tool] Made by AI: attest AI authorship of HEAD (an Authored-by-ai trailer).
53
+ --model <version> names the model too (Authored-by-ai: <tool>:<version>).
52
54
  `--witness [log]` instead records witnessed spans from a session log.
53
55
  madeby me [--with-ai [t]] Made by me: affirm you authored HEAD (Authored-by-human); --with-ai adds
54
- Assisted-by → with_ai.
56
+ Assisted-by → with_ai. --model <version> names the model too.
55
57
  madeby mcp Run the MCP server (stdio) — the tools, agent-callable.
56
58
  madeby help
57
59
  ```
@@ -97,12 +99,20 @@ authorship coverage — last 500 commits (estimate · disclosure, not detection
97
99
  of the 310 known: human 41% · with_ai 38% · ai 21%
98
100
  trust: asserted 88% · verified 12% — self-reported; sign commits to verify
99
101
 
102
+ agents & models named in the 183 AI commits:
103
+ Claude Code 121 66%
104
+ Claude Opus 5.5 98 54%
105
+ Cursor 62 34%
106
+
100
107
  → 38% unknown. Require disclosure going forward: madeby init --required
101
108
  ```
102
109
 
103
110
  It is a **labeled estimate**, honest by construction: an undisclosed commit is **unknown**, never
104
111
  inferred as human — disclosure, not detection. The human/with_ai/ai mix is reported *of the known*,
105
- never of the whole repo, and the asserted-vs-verified split shows how much is signed. `--json` emits
112
+ never of the whole repo, and the asserted-vs-verified split shows how much is signed. The agents and
113
+ model versions are listed exactly as the AI commits name them — a commit naming a tool and its model
114
+ (`Assisted-by: Claude Code` + `Co-Authored-By: Claude Opus 5.5`) counts under both, so those rows can
115
+ sum past 100%. `--json` emits
106
116
  counts only (percentages are derived at the edge, never stored). A plain merge authors nothing and
107
117
  isn't scored; a merge that changes content of its own (a conflict resolution) is, like any other
108
118
  commit — chosen by what it changes, never by whether it discloses. Use it to see your gap, then
@@ -140,6 +150,15 @@ Two modes, nothing more:
140
150
  - `required` — fail on any commit that discloses nothing your policy accepts.
141
151
  - optional `"accept": ["dco-signoff", "ai-trailer", …]` — restrict what satisfies the policy
142
152
  (omit ⇒ any recognized disclosure counts).
153
+ - optional `"recordModel": true` — written by `madeby init --record-model`: the repo asks AI
154
+ disclosures to name the model version as well as the tool. It adds one line to the AGENTS.md rule,
155
+ and `me --with-ai` / `ai` then read the model from the **tool's own record of the session** — Cursor's
156
+ `CURSOR_MODEL`, the Copilot CLI and Claude Code session logs (locally; only the model id is read) —
157
+ which wins over an agent's `--model`, since an agent routed by "Auto" may not know which model served
158
+ it. Elsewhere `--model <version>` is used, never guessed. So `coverage` can list each **agent · model** pairing
159
+ (`Cursor · gpt-5.3-codex` vs `Claude Code · claude-sonnet-5`). A request, not a gate — an AI
160
+ disclosure without a model still satisfies the policy. Opt-in because naming models is a project's
161
+ call: the Linux kernel, for one, asks only that an LLM was used, not which.
143
162
 
144
163
  (There's no "off" — that's just not running the check. Advisory is the floor.)
145
164
 
package/dist/madeby.mjs CHANGED
@@ -86,6 +86,20 @@ function matchAi(text) {
86
86
  }
87
87
  return null;
88
88
  }
89
+ var TOOL_MODEL = /^([^:\s<>][^:<>]*?)[ \t]*:[ \t]*([^\s:<>/][^:<>]*?)$/;
90
+ function splitToolModel(value) {
91
+ const v = value.replace(/<[^>]*>/g, "").trim();
92
+ const m = TOOL_MODEL.exec(v);
93
+ return m ? { tool: m[1].trim(), model: m[2].trim() } : null;
94
+ }
95
+ function matchAiTrailer(who) {
96
+ const tm = splitToolModel(who);
97
+ if (tm) {
98
+ const p = matchAi(tm.tool) ?? matchAi(tm.model) ?? genericAiDisclosure(tm.tool);
99
+ if (p) return { ...p, raw: who.trim(), tool: tm.tool, modelVersion: tm.model };
100
+ }
101
+ return matchAi(who) ?? genericAiDisclosure(who);
102
+ }
89
103
  var GENERIC_AI_TRAILER = /^(?:AI|an? AI|AI (?:assistant|tool|pair)|artificial intelligence)$/i;
90
104
  function genericAiDisclosure(who) {
91
105
  const w = who.trim();
@@ -125,13 +139,13 @@ function classifyCommit(commit) {
125
139
  const aiAuthored = [...commit.message.matchAll(AI_AUTHOR_TRAILER)].map((m) => m[1].trim());
126
140
  if (aiAuthored.length > 0 && !HUMAN_ATTEST.test(commit.message)) {
127
141
  const who = aiAuthored[0];
128
- const c = matchAi(who) ?? { provider: "unspecified", raw: who };
142
+ const c = matchAiTrailer(who) ?? { provider: "unspecified", raw: who };
129
143
  return { class: "ai", confidence: CONF_AI, signals: [`authored-by-ai:${c.provider}`], aiContributors: [c] };
130
144
  }
131
145
  const trailers = [...commit.message.matchAll(TRAILER_RE)].map((m) => m[1].trim());
132
146
  let humanCoAuthors = 0;
133
147
  for (const who of trailers) {
134
- const ai = matchAi(who) ?? genericAiDisclosure(who);
148
+ const ai = matchAiTrailer(who);
135
149
  if (ai) {
136
150
  aiContributors.push(ai);
137
151
  signals.push(`co-authored-by:${ai.provider}`);
@@ -140,7 +154,7 @@ function classifyCommit(commit) {
140
154
  }
141
155
  }
142
156
  for (const who of aiAuthored) {
143
- const ai = matchAi(who) ?? { provider: "unspecified", raw: who };
157
+ const ai = matchAiTrailer(who) ?? { provider: "unspecified", raw: who };
144
158
  aiContributors.push(ai);
145
159
  signals.push(`authored-by-ai:${ai.provider}`);
146
160
  }
@@ -925,7 +939,12 @@ function parseDisclosurePolicy(input) {
925
939
  const mode = MODES.includes(o.mode) ? o.mode : "advisory";
926
940
  const version = typeof o.version === "number" ? o.version : 0;
927
941
  const accept = Array.isArray(o.accept) ? o.accept.filter((k) => typeof k === "string") : void 0;
928
- return accept && accept.length > 0 ? { version, mode, accept } : { version, mode };
942
+ return {
943
+ version,
944
+ mode,
945
+ ...accept && accept.length > 0 ? { accept } : {},
946
+ ...o.recordModel === true ? { recordModel: true } : {}
947
+ };
929
948
  }
930
949
  function evaluateDisclosurePolicy(policy, commits) {
931
950
  const accept = policy.accept;
@@ -1231,6 +1250,10 @@ function provenanceMap(root, opts = {}) {
1231
1250
  }
1232
1251
 
1233
1252
  // ../analyzer/src/coverage.ts
1253
+ function agentName(raw) {
1254
+ const name = raw.replace(/<[^>]*>/g, " ").split(/\s+/).filter((w) => !w.includes("@")).join(" ").replace(/(\s*\([^)]*\))+\s*$/, "").trim();
1255
+ return name || raw.trim();
1256
+ }
1234
1257
  function repoCoverage(root, opts = {}) {
1235
1258
  const limit = opts.range ? void 0 : opts.limit ?? 500;
1236
1259
  const authored = readGitLog(root, limit, opts.range);
@@ -1241,15 +1264,29 @@ function repoCoverage(root, opts = {}) {
1241
1264
  const byCategory = { human: 0, with_ai: 0, ai: 0, bot: 0 };
1242
1265
  const byTier = { asserted: 0, verified: 0 };
1243
1266
  let known = 0;
1267
+ const agents = /* @__PURE__ */ new Map();
1244
1268
  for (const c of commits) {
1245
1269
  const kinds = commitDisclosureKinds({ message: c.message, signed: c.signed });
1246
- const cls = classifyCommit({ message: c.message, authorName: c.authorName, authorEmail: c.authorEmail, signed: c.signed, sha: c.sha }).class;
1270
+ const { class: cls, aiContributors } = classifyCommit({ message: c.message, authorName: c.authorName, authorEmail: c.authorEmail, signed: c.signed, sha: c.sha });
1247
1271
  const category = cls === "human" && !kinds.includes("human-attestation") ? "unknown" : cls;
1248
1272
  if (category === "unknown") continue;
1249
1273
  known += 1;
1250
1274
  byCategory[category] += 1;
1251
1275
  if (c.signed) byTier.verified += 1;
1252
1276
  else byTier.asserted += 1;
1277
+ if (category === "with_ai" || category === "ai") {
1278
+ const named = /* @__PURE__ */ new Set();
1279
+ for (const a of aiContributors) {
1280
+ const paired = a.tool && a.modelVersion ? { tool: a.tool, model: a.modelVersion } : void 0;
1281
+ const name = paired ? `${paired.tool} \xB7 ${paired.model}` : agentName(a.raw);
1282
+ const key = name.toLowerCase();
1283
+ if (named.has(key)) continue;
1284
+ named.add(key);
1285
+ const e = agents.get(key) ?? { name, provider: a.provider, ...paired, commits: 0 };
1286
+ e.commits += 1;
1287
+ agents.set(key, e);
1288
+ }
1289
+ }
1253
1290
  }
1254
1291
  return {
1255
1292
  isEstimate: true,
@@ -1259,7 +1296,8 @@ function repoCoverage(root, opts = {}) {
1259
1296
  known,
1260
1297
  unknown: commits.length - known,
1261
1298
  byCategory,
1262
- byTier
1299
+ byTier,
1300
+ byAgent: [...agents.values()].sort((a, b) => b.commits - a.commits || a.name.localeCompare(b.name))
1263
1301
  };
1264
1302
  }
1265
1303
 
@@ -1795,6 +1833,94 @@ To disclose: add a Co-Authored-By / Generated-by trailer, affirm your own author
1795
1833
 
1796
1834
  // src/commands/me.ts
1797
1835
  import { execFileSync as execFileSync5 } from "node:child_process";
1836
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
1837
+ import { homedir as homedir3 } from "node:os";
1838
+ import { join as join6 } from "node:path";
1839
+
1840
+ // src/model-source.ts
1841
+ import { closeSync, existsSync as existsSync4, openSync, readdirSync as readdirSync3, readSync, statSync as statSync2 } from "node:fs";
1842
+ import { homedir as homedir2 } from "node:os";
1843
+ import { join as join5 } from "node:path";
1844
+ var SESSION_ID = /^[0-9a-f-]{8,64}$/i;
1845
+ var TAIL_BYTES = 2 * 1024 * 1024;
1846
+ function lastModelIn(path) {
1847
+ let fd;
1848
+ try {
1849
+ const size = statSync2(path).size;
1850
+ const len = Math.min(size, TAIL_BYTES);
1851
+ const buf = Buffer.alloc(len);
1852
+ fd = openSync(path, "r");
1853
+ readSync(fd, buf, 0, len, size - len);
1854
+ const models = [...buf.toString("utf8").matchAll(/"(?:model|newModel)"\s*:\s*"([^"<][^"]{0,80})"/g)].map((m) => m[1]);
1855
+ return models.at(-1) ?? null;
1856
+ } catch {
1857
+ return null;
1858
+ } finally {
1859
+ if (fd !== void 0) closeSync(fd);
1860
+ }
1861
+ }
1862
+ var MODEL_SOURCES = [
1863
+ {
1864
+ tool: "Cursor",
1865
+ matches: /\bcursor/i,
1866
+ detect: (env) => env.CURSOR_AGENT === "1" && env.CURSOR_MODEL?.trim() ? { model: env.CURSOR_MODEL.trim(), source: "Cursor's CURSOR_MODEL" } : null
1867
+ },
1868
+ {
1869
+ tool: "GitHub Copilot CLI",
1870
+ matches: /copilot/i,
1871
+ detect: (env, home) => {
1872
+ const id = env.COPILOT_AGENT_SESSION_ID;
1873
+ if (env.COPILOT_CLI !== "1" || !id || !SESSION_ID.test(id)) return null;
1874
+ const model = lastModelIn(join5(home, ".copilot", "session-state", id, "events.jsonl"));
1875
+ return model ? { model, source: "Copilot CLI's session log" } : null;
1876
+ }
1877
+ },
1878
+ {
1879
+ tool: "Claude Code",
1880
+ matches: /claude/i,
1881
+ detect: (env, home) => {
1882
+ const id = env.CLAUDE_CODE_SESSION_ID;
1883
+ if (env.CLAUDECODE !== "1" || !id || !SESSION_ID.test(id)) return null;
1884
+ const projects = join5(home, ".claude", "projects");
1885
+ if (!existsSync4(projects)) return null;
1886
+ try {
1887
+ for (const dir of readdirSync3(projects)) {
1888
+ const p = join5(projects, dir, `${id}.jsonl`);
1889
+ if (existsSync4(p)) {
1890
+ const model = lastModelIn(p);
1891
+ return model ? { model, source: "Claude Code's session transcript" } : null;
1892
+ }
1893
+ }
1894
+ } catch {
1895
+ }
1896
+ return null;
1897
+ }
1898
+ }
1899
+ ];
1900
+ function detectAgentModel(tool, env = process.env, home = homedir2()) {
1901
+ const generic = /^(?:AI|an? AI)$/i.test(tool.trim());
1902
+ for (const s of MODEL_SOURCES) {
1903
+ if (!generic && !s.matches.test(tool)) continue;
1904
+ const hit = s.detect(env, home);
1905
+ if (hit) return { tool: generic ? s.tool : tool, ...hit };
1906
+ }
1907
+ return null;
1908
+ }
1909
+
1910
+ // src/commands/me.ts
1911
+ function parseModel(args) {
1912
+ const i = args.findIndex((a2) => a2 === "--model" || a2.startsWith("--model="));
1913
+ if (i === -1) return void 0;
1914
+ const a = args[i];
1915
+ const v = a.startsWith("--model=") ? a.slice("--model=".length) : args[i + 1];
1916
+ return v && !v.startsWith("-") ? v : void 0;
1917
+ }
1918
+ function aiValue(tool, model) {
1919
+ const line = (s) => s.replace(/\s+/g, " ").trim();
1920
+ const t = line(tool.replace(/:/g, " ")) || "AI";
1921
+ const m = model ? line(model) : "";
1922
+ return m ? `${t}:${m}` : t;
1923
+ }
1798
1924
  function parseWithAi(args) {
1799
1925
  const i = args.findIndex((a2) => a2 === "--with-ai" || a2.startsWith("--with-ai="));
1800
1926
  if (i === -1) return { withAi: false, tool: "AI" };
@@ -1808,10 +1934,27 @@ function git3(root, args) {
1808
1934
  }
1809
1935
  var HUMAN_ATTEST2 = /^[ \t]*(?:Authored-by-human|Human-authored(?:-by)?)[ \t]*:/im;
1810
1936
  var ASSIST = /^[ \t]*Assisted-by[ \t]*:/im;
1937
+ function recordsModel(root) {
1938
+ const p = join6(root, ".madeby", "policy.json");
1939
+ if (!existsSync5(p)) return false;
1940
+ try {
1941
+ return parseDisclosurePolicy(JSON.parse(readFileSync6(p, "utf8"))).recordModel === true;
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ }
1946
+ function resolveAi(root, o) {
1947
+ const tool = o.tool && o.tool.trim() || "AI";
1948
+ const detected = recordsModel(root) ? detectAgentModel(tool, o.env ?? process.env, o.home ?? homedir3()) : null;
1949
+ if (detected) return { value: aiValue(detected.tool, detected.model), tool: detected.tool, model: detected.model, modelSource: detected.source };
1950
+ const model = o.model?.trim() || void 0;
1951
+ return { value: aiValue(tool, model), tool, ...model ? { model, modelSource: "as given (--model)" } : {} };
1952
+ }
1811
1953
  var AI_AUTHOR = /^[ \t]*Authored-by-ai[ \t]*:/im;
1812
1954
  function attestAiAuthorship(root, opts = {}) {
1813
- const tool = opts.tool && opts.tool.trim() || "AI";
1814
- const base = { ok: false, changed: false, withAi: false, tool, added: [], category: "ai" };
1955
+ const ai = resolveAi(root, opts);
1956
+ const tool = ai.value;
1957
+ const base = { ok: false, changed: false, withAi: false, tool, model: ai.model, modelSource: ai.modelSource, added: [], category: "ai" };
1815
1958
  try {
1816
1959
  execFileSync5("git", ["-C", root, "diff", "--cached", "--quiet"]);
1817
1960
  } catch {
@@ -1832,14 +1975,15 @@ function attestAiAuthorship(root, opts = {}) {
1832
1975
  added.push("Authored-by-ai");
1833
1976
  }
1834
1977
  if (trailers.length > 0) {
1835
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", ...trailers]);
1978
+ execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
1836
1979
  }
1837
- return { ok: true, changed: trailers.length > 0, withAi: false, tool, subject, added, category: "ai" };
1980
+ return { ...base, ok: true, changed: trailers.length > 0, subject, added };
1838
1981
  }
1839
1982
  function affirmAuthorship(root, opts = {}) {
1840
1983
  const withAi = opts.withAi ?? false;
1841
- const tool = opts.tool && opts.tool.trim() || "AI";
1842
- const base = { ok: false, changed: false, withAi, tool, added: [], category: withAi ? "with_ai" : "human" };
1984
+ const ai = withAi ? resolveAi(root, opts) : void 0;
1985
+ const tool = ai?.value ?? "AI";
1986
+ const base = { ok: false, changed: false, withAi, tool, model: ai?.model, modelSource: ai?.modelSource, added: [], category: withAi ? "with_ai" : "human" };
1843
1987
  let name = "";
1844
1988
  let email = "";
1845
1989
  try {
@@ -1874,20 +2018,26 @@ function affirmAuthorship(root, opts = {}) {
1874
2018
  added.push("Assisted-by");
1875
2019
  }
1876
2020
  if (trailers.length > 0) {
1877
- execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", ...trailers]);
2021
+ execFileSync5("git", ["-C", root, "commit", "--amend", "--no-edit", "--allow-empty", ...trailers]);
1878
2022
  }
1879
2023
  const category = hasAssist || added.includes("Assisted-by") ? "with_ai" : "human";
1880
- return { ok: true, changed: trailers.length > 0, withAi, tool, name, email, subject, added, category };
2024
+ return { ...base, ok: true, changed: trailers.length > 0, name, email, subject, added, category };
1881
2025
  }
1882
2026
  function meCommand(args) {
1883
2027
  const { withAi, tool } = parseWithAi(args);
2028
+ const model = parseModel(args);
2029
+ if (model && !withAi) {
2030
+ console.error("madeby me: --model names the AI's model, so it goes with --with-ai (e.g. madeby me --with-ai Cursor --model claude-sonnet-5).");
2031
+ process.exit(1);
2032
+ return;
2033
+ }
1884
2034
  const root = repoRoot();
1885
2035
  if (!root) {
1886
2036
  console.error("madeby me: not a git repository.");
1887
2037
  process.exit(2);
1888
2038
  return;
1889
2039
  }
1890
- const r = affirmAuthorship(root, { withAi, tool });
2040
+ const r = affirmAuthorship(root, { withAi, tool, model });
1891
2041
  if (!r.ok) {
1892
2042
  if (r.reason === "no-identity") console.error("madeby me: set your git identity first (git config user.name / user.email).");
1893
2043
  else if (r.reason === "staged-changes") console.error("madeby me: you have staged changes. Commit them first \u2014 `me` affirms HEAD, not your index.");
@@ -1902,7 +2052,8 @@ function meCommand(args) {
1902
2052
  return;
1903
2053
  }
1904
2054
  console.log(
1905
- withAi ? `madeby me: made by me, with AI \u2014 affirmed human authorship + disclosed AI assistance on HEAD \u2014 "${r.subject}" (Authored-by-human: ${r.name}; Assisted-by: ${r.tool}).` : `madeby me: affirmed human authorship on HEAD \u2014 "${r.subject}" (Authored-by-human: ${r.name}).`
2055
+ withAi ? `madeby me: made by me, with AI \u2014 affirmed human authorship + disclosed AI assistance on HEAD \u2014 "${r.subject}" (Authored-by-human: ${r.name}; Assisted-by: ${r.tool}).${r.modelSource && r.modelSource !== "as given (--model)" ? `
2056
+ model from ${r.modelSource}.` : ""}` : `madeby me: affirmed human authorship on HEAD \u2014 "${r.subject}" (Authored-by-human: ${r.name}).`
1906
2057
  );
1907
2058
  }
1908
2059
 
@@ -1917,7 +2068,8 @@ function aiCommand(args) {
1917
2068
  }
1918
2069
  const makeSample = args.includes("--make-parser-sample");
1919
2070
  const witness = args.includes("--witness");
1920
- const positional = args.filter((a) => !a.startsWith("--"));
2071
+ const model = parseModel(args);
2072
+ const positional = args.filter((a, i) => !a.startsWith("--") && !(args[i - 1] === "--model"));
1921
2073
  if (makeSample) {
1922
2074
  const s = writeParserSample(root, { transcriptPath: positional[0] });
1923
2075
  if (s.error === "transcript-not-found") {
@@ -1959,7 +2111,7 @@ function aiCommand(args) {
1959
2111
  return;
1960
2112
  }
1961
2113
  const tool = positional[0] || "AI";
1962
- const r = attestAiAuthorship(root, { tool });
2114
+ const r = attestAiAuthorship(root, { tool, model });
1963
2115
  if (!r.ok) {
1964
2116
  if (r.reason === "staged-changes") {
1965
2117
  console.error("madeby ai: you have staged changes. Commit them first \u2014 `ai` attests HEAD, not your index.");
@@ -1976,7 +2128,8 @@ function aiCommand(args) {
1976
2128
  }
1977
2129
  console.log(
1978
2130
  `madeby ai: attested AI authorship on HEAD \u2014 "${r.subject}" (Authored-by-ai: ${r.tool}).
1979
- \u2192 asserted tier. For evidence-backed spans from your session log: madeby ai --witness [log].`
2131
+ ` + (r.modelSource && r.modelSource !== "as given (--model)" ? ` model from ${r.modelSource}.
2132
+ ` : "") + " \u2192 asserted tier. For evidence-backed spans from your session log: madeby ai --witness [log]."
1980
2133
  );
1981
2134
  }
1982
2135
 
@@ -2028,17 +2181,18 @@ function whoCommand(args) {
2028
2181
  }
2029
2182
 
2030
2183
  // src/commands/coverage.ts
2031
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "node:fs";
2032
- import { join as join5 } from "node:path";
2184
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
2185
+ import { join as join7 } from "node:path";
2033
2186
  function policyIsRequired(root) {
2034
- const p = join5(root, ".madeby", "policy.json");
2035
- if (!existsSync4(p)) return false;
2187
+ const p = join7(root, ".madeby", "policy.json");
2188
+ if (!existsSync6(p)) return false;
2036
2189
  try {
2037
- return parseDisclosurePolicy(JSON.parse(readFileSync6(p, "utf8"))).mode === "required";
2190
+ return parseDisclosurePolicy(JSON.parse(readFileSync7(p, "utf8"))).mode === "required";
2038
2191
  } catch {
2039
2192
  return false;
2040
2193
  }
2041
2194
  }
2195
+ var AGENT_ROWS = 8;
2042
2196
  var pct = (n, d) => d > 0 ? Math.round(n / d * 100) : 0;
2043
2197
  var bar = (p) => "\u2588".repeat(Math.round(p / 5)) + "\u2591".repeat(20 - Math.round(p / 5));
2044
2198
  function render(r, required) {
@@ -2059,6 +2213,16 @@ function render(r, required) {
2059
2213
  of the ${r.known} known: ${parts.join(" \xB7 ")}`);
2060
2214
  console.log(` trust: asserted ${pct(r.byTier.asserted, r.known)}% \xB7 verified ${pct(r.byTier.verified, r.known)}% \u2014 self-reported; sign commits to verify`);
2061
2215
  }
2216
+ const withAi = r.byCategory.with_ai + r.byCategory.ai;
2217
+ if (r.byAgent.length > 0) {
2218
+ console.log(`
2219
+ agents & models named in the ${withAi} AI commits:`);
2220
+ const width = Math.max(...r.byAgent.slice(0, AGENT_ROWS).map((a) => a.name.length));
2221
+ for (const a of r.byAgent.slice(0, AGENT_ROWS)) {
2222
+ console.log(` ${a.name.padEnd(width)} ${String(a.commits).padStart(4)} ${String(pct(a.commits, withAi)).padStart(3)}%`);
2223
+ }
2224
+ if (r.byAgent.length > AGENT_ROWS) console.log(` \u2026 and ${r.byAgent.length - AGENT_ROWS} more (--json for all)`);
2225
+ }
2062
2226
  if (r.unknown > 0) {
2063
2227
  console.log(
2064
2228
  required ? `
@@ -2085,8 +2249,8 @@ function coverageCommand(args) {
2085
2249
  }
2086
2250
 
2087
2251
  // src/init-repo.ts
2088
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
2089
- import { dirname as dirname2, join as join6 } from "node:path";
2252
+ import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "node:fs";
2253
+ import { dirname as dirname2, join as join8 } from "node:path";
2090
2254
  var ACO_VERSION = 1;
2091
2255
  var ACO_OPEN = `<!-- madeby:aco v${ACO_VERSION} -->`;
2092
2256
  var ACO_CLOSE = `<!-- /madeby:aco -->`;
@@ -2103,14 +2267,16 @@ After you commit, run the command for the honest category (no install, no setup)
2103
2267
 
2104
2268
  Disclose per commit, honestly. Undisclosed content stays *unknown*, never guessed.
2105
2269
  `;
2106
- var ACO_BLOCK = `${ACO_OPEN}
2107
- ${ACO_BODY}${ACO_CLOSE}
2270
+ var MODEL_LINE = `
2271
+ **This repo also records the model.** Name your tool as usual \u2014 madeby reads the model from the
2272
+ tool's own record of the session where it can (Cursor, Copilot CLI, Claude Code), e.g.
2273
+ \`npx madeby@latest me --with-ai Cursor\` writes \`Assisted-by: Cursor:<model>\`. Elsewhere add
2274
+ \`--model <version>\` with the exact model you are running as. If you don't know it, leave it off \u2014
2275
+ never guess.
2276
+ `;
2277
+ var acoBlock = (recordModel) => `${ACO_OPEN}
2278
+ ${ACO_BODY}${recordModel ? MODEL_LINE : ""}${ACO_CLOSE}
2108
2279
  `;
2109
- var AGENTS_CONVENTION = `# AGENTS.md
2110
-
2111
- ${ACO_BLOCK}`;
2112
- var ACO_SECTION = `
2113
- ${ACO_BLOCK}`;
2114
2280
  function findAcoBlock(content) {
2115
2281
  const m = ACO_OPEN_RE.exec(content);
2116
2282
  if (!m) return null;
@@ -2133,11 +2299,11 @@ function acoStatus(content) {
2133
2299
  if (loc.version > ACO_VERSION) return "newer";
2134
2300
  return "current";
2135
2301
  }
2136
- function replaceAcoBlock(content, loc) {
2137
- return content.slice(0, loc.start) + ACO_BLOCK + content.slice(loc.end);
2302
+ function replaceAcoBlock(content, loc, block) {
2303
+ return content.slice(0, loc.start) + block + content.slice(loc.end);
2138
2304
  }
2139
2305
  function writeIfAbsent(abs, rel, content, created, skipped) {
2140
- if (existsSync5(abs)) {
2306
+ if (existsSync7(abs)) {
2141
2307
  skipped.push(rel);
2142
2308
  return;
2143
2309
  }
@@ -2145,47 +2311,60 @@ function writeIfAbsent(abs, rel, content, created, skipped) {
2145
2311
  writeFileSync2(abs, content);
2146
2312
  created.push(rel);
2147
2313
  }
2148
- function ensurePolicy(abs, rel, mode, res) {
2149
- if (!existsSync5(abs)) {
2314
+ function ensurePolicy(abs, rel, want, res) {
2315
+ if (!existsSync7(abs)) {
2150
2316
  mkdirSync2(dirname2(abs), { recursive: true });
2151
- writeFileSync2(abs, JSON.stringify({ version: 0, mode }, null, 2) + "\n");
2317
+ const fresh = { version: 0, mode: want.mode, ...want.recordModel ? { recordModel: true } : {} };
2318
+ writeFileSync2(abs, JSON.stringify(fresh, null, 2) + "\n");
2152
2319
  res.created.push(rel);
2153
- return { mode, malformed: false };
2320
+ return { mode: want.mode, recordModel: want.recordModel, malformed: false };
2154
2321
  }
2155
2322
  let raw;
2156
2323
  try {
2157
- raw = JSON.parse(readFileSync7(abs, "utf8"));
2324
+ raw = JSON.parse(readFileSync8(abs, "utf8"));
2158
2325
  } catch {
2159
2326
  res.skipped.push(rel);
2160
- return { mode: "advisory", malformed: true };
2327
+ return { mode: "advisory", recordModel: false, malformed: true };
2161
2328
  }
2162
2329
  const isObject = !!raw && typeof raw === "object" && !Array.isArray(raw);
2163
- const current = parseDisclosurePolicy(raw).mode;
2164
- if (mode === "required" && current !== "required" && isObject) {
2165
- writeFileSync2(abs, JSON.stringify({ ...raw, mode: "required" }, null, 2) + "\n");
2166
- res.raised.push(rel);
2167
- return { mode: "required", malformed: false };
2330
+ const current = parseDisclosurePolicy(raw);
2331
+ const raise = want.mode === "required" && current.mode !== "required" && isObject;
2332
+ const modelOn = want.recordModel && !current.recordModel && isObject;
2333
+ if (raise || modelOn) {
2334
+ const next = { ...raw, ...raise ? { mode: "required" } : {}, ...modelOn ? { recordModel: true } : {} };
2335
+ writeFileSync2(abs, JSON.stringify(next, null, 2) + "\n");
2336
+ if (raise) res.raised.push(rel);
2337
+ if (modelOn) res.modelOn.push(rel);
2338
+ } else {
2339
+ res.skipped.push(rel);
2168
2340
  }
2169
- res.skipped.push(rel);
2170
- return { mode: current, malformed: !isObject };
2341
+ return { mode: raise ? "required" : current.mode, recordModel: modelOn || current.recordModel === true, malformed: !isObject };
2171
2342
  }
2172
2343
  function ensureAco(abs, rel, res, opts) {
2173
- if (!existsSync5(abs)) {
2344
+ const block = acoBlock(opts.recordModel);
2345
+ if (!existsSync7(abs)) {
2174
2346
  if (!opts.createIfAbsent) return;
2175
2347
  mkdirSync2(dirname2(abs), { recursive: true });
2176
- writeFileSync2(abs, AGENTS_CONVENTION);
2348
+ writeFileSync2(abs, `# AGENTS.md
2349
+
2350
+ ${block}`);
2177
2351
  res.created.push(rel);
2178
2352
  return;
2179
2353
  }
2180
- const content = readFileSync7(abs, "utf8");
2354
+ const content = readFileSync8(abs, "utf8");
2181
2355
  const status = acoStatus(content);
2356
+ const loc = findAcoBlock(content);
2182
2357
  if (status === "absent") {
2183
2358
  const sep = content.endsWith("\n") ? "" : "\n";
2184
- writeFileSync2(abs, content + sep + ACO_SECTION);
2359
+ writeFileSync2(abs, content + sep + `
2360
+ ${block}`);
2185
2361
  res.merged.push(rel);
2362
+ } else if (status === "current" && opts.modelRequested && loc && content.slice(loc.start, loc.end) !== block) {
2363
+ writeFileSync2(abs, replaceAcoBlock(content, loc, block));
2364
+ res.updated.push(rel);
2186
2365
  } else if (status === "outdated") {
2187
2366
  if (opts.update) {
2188
- writeFileSync2(abs, replaceAcoBlock(content, findAcoBlock(content)));
2367
+ writeFileSync2(abs, replaceAcoBlock(content, loc, block));
2189
2368
  res.updated.push(rel);
2190
2369
  } else {
2191
2370
  res.outdated.push(rel);
@@ -2204,17 +2383,23 @@ function initRepo(root, opts = {}) {
2204
2383
  const updated = [];
2205
2384
  const outdated = [];
2206
2385
  const raised = [];
2207
- const res = { created, skipped, merged, updated, outdated, raised };
2208
- const policy = ensurePolicy(join6(root, ".madeby", "policy.json"), ".madeby/policy.json", mode, res);
2209
- ensureAco(join6(root, "AGENTS.md"), "AGENTS.md", res, { createIfAbsent: true, update });
2210
- ensureAco(join6(root, "CLAUDE.md"), "CLAUDE.md", res, { createIfAbsent: false, update });
2386
+ const modelOn = [];
2387
+ const res = { created, skipped, merged, updated, outdated, raised, modelOn };
2388
+ const modelRequested = opts.recordModel ?? false;
2389
+ const policy = ensurePolicy(join8(root, ".madeby", "policy.json"), ".madeby/policy.json", { mode, recordModel: modelRequested }, res);
2390
+ const aco = { update, recordModel: policy.recordModel, modelRequested };
2391
+ ensureAco(join8(root, "AGENTS.md"), "AGENTS.md", res, { createIfAbsent: true, ...aco });
2392
+ ensureAco(join8(root, "CLAUDE.md"), "CLAUDE.md", res, { createIfAbsent: false, ...aco });
2211
2393
  if (host === "github") {
2212
- writeIfAbsent(join6(root, ".github", "workflows", "disclosure.yml"), ".github/workflows/disclosure.yml", GH_WORKFLOW, created, skipped);
2394
+ writeIfAbsent(join8(root, ".github", "workflows", "disclosure.yml"), ".github/workflows/disclosure.yml", GH_WORKFLOW, created, skipped);
2213
2395
  }
2214
2396
  const nextSteps = ["Commit the created files."];
2215
2397
  if (raised.length > 0) {
2216
2398
  nextSteps.push('Raised .madeby/policy.json to "required" \u2014 undisclosed commits now fail the check. To make it block merges, also mark the check required on the protected branch (see madeby://guide/enforce).');
2217
2399
  }
2400
+ if (modelOn.length > 0 || modelRequested && created.includes(".madeby/policy.json")) {
2401
+ nextSteps.push("This repo now asks AI disclosures to name the model version (recordModel in .madeby/policy.json; the AGENTS.md rule says how: --model <version>). `madeby coverage` lists each agent \xB7 model pairing. It never changes the gate.");
2402
+ }
2218
2403
  if (policy.malformed) {
2219
2404
  nextSteps.push('.madeby/policy.json is not a valid policy, so the check treats it as advisory. It was left untouched \u2014 fix it (e.g. { "version": 0, "mode": "required" }) to enforce.');
2220
2405
  }
@@ -2240,7 +2425,7 @@ function initRepo(root, opts = {}) {
2240
2425
  }
2241
2426
  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.");
2242
2427
  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.");
2243
- return { created, skipped, merged, updated, outdated, raised, policyMode: policy.mode, host, nextSteps };
2428
+ return { created, skipped, merged, updated, outdated, raised, modelOn, policyMode: policy.mode, recordModel: policy.recordModel, host, nextSteps };
2244
2429
  }
2245
2430
  var GH_WORKFLOW = `name: Disclosure
2246
2431
  on: pull_request
@@ -2264,17 +2449,19 @@ function initCommand(args) {
2264
2449
  }
2265
2450
  const mode = args.includes("--required") ? "required" : "advisory";
2266
2451
  const update = args.includes("--update");
2452
+ const recordModel = args.includes("--record-model");
2267
2453
  const hostIdx = args.indexOf("--host");
2268
2454
  const host = hostIdx >= 0 ? args[hostIdx + 1] : void 0;
2269
- const r = initRepo(root, { mode, host, update });
2455
+ const r = initRepo(root, { mode, host, update, recordModel });
2270
2456
  for (const f of r.created) console.log(` + ${f}`);
2271
2457
  for (const f of r.merged) console.log(` ~ ${f} (merged in the ACO rule)`);
2272
2458
  for (const f of r.updated) console.log(` \u2191 ${f} (ACO convention refreshed to the current version)`);
2273
2459
  for (const f of r.raised) console.log(` \u2191 ${f} (mode raised to required)`);
2460
+ for (const f of r.modelOn) console.log(` \u2191 ${f} (now records the model version)`);
2274
2461
  for (const f of r.outdated) console.log(` ! ${f} (ACO convention OUTDATED \u2014 run 'madeby init --update')`);
2275
2462
  for (const f of r.skipped) console.log(` \xB7 ${f} (exists \u2014 left as-is)`);
2276
2463
  console.log(`
2277
- madeby init \u2014 policy: ${r.policyMode}, host: ${r.host}`);
2464
+ madeby init \u2014 policy: ${r.policyMode}${r.recordModel ? " + records model" : ""}, host: ${r.host}`);
2278
2465
  console.log("Next:");
2279
2466
  for (const s of r.nextSteps) console.log(` - ${s}`);
2280
2467
  }
@@ -2283,7 +2470,7 @@ madeby init \u2014 policy: ${r.policyMode}, host: ${r.host}`);
2283
2470
  import { execFileSync as execFileSync6 } from "node:child_process";
2284
2471
  import { createInterface } from "node:readline";
2285
2472
  var DEFAULT_PROTOCOL = "2024-11-05";
2286
- var SERVER_INFO = { name: "madeby", version: "0.3.8" };
2473
+ var SERVER_INFO = { name: "madeby", version: "0.3.9" };
2287
2474
  function rootFor(path) {
2288
2475
  const cwd = path && path.length ? path : process.cwd();
2289
2476
  return execFileSync6("git", ["-C", cwd, "-c", "core.quotePath=false", "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
@@ -2305,10 +2492,16 @@ var TOOLS = [
2305
2492
  path: { type: "string", description: "path to the repo (default: the server's working directory)" },
2306
2493
  mode: { enum: ["advisory", "required"], description: "policy mode to seed (default: advisory)" },
2307
2494
  host: { type: "string", description: "CI host to wire: github (default) or azure-devops" },
2308
- update: { type: "boolean", description: "refresh an outdated ACO convention block in place (drift update); default false (report only)" }
2495
+ update: { type: "boolean", description: "refresh an outdated ACO convention block in place (drift update); default false (report only)" },
2496
+ record_model: { type: "boolean", description: "opt in: ask AI disclosures to also name the model version (tool:model) \u2014 policy recordModel + an AGENTS.md line. Never turned off by init; default false." }
2309
2497
  }
2310
2498
  },
2311
- handler: (args) => initRepo(rootFor(args.path), { mode: args.mode, host: args.host, update: args.update })
2499
+ handler: (args) => initRepo(rootFor(args.path), {
2500
+ mode: args.mode,
2501
+ host: args.host,
2502
+ update: args.update,
2503
+ recordModel: args.record_model
2504
+ })
2312
2505
  },
2313
2506
  {
2314
2507
  name: "check",
@@ -2318,7 +2511,7 @@ var TOOLS = [
2318
2511
  },
2319
2512
  {
2320
2513
  name: "coverage",
2321
- 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), and the asserted-vs-verified trust split. 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.",
2514
+ 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.",
2322
2515
  inputSchema: repoInput,
2323
2516
  handler: (args) => repoCoverage(rootFor(args.path), { range: args.range })
2324
2517
  },
@@ -2350,15 +2543,18 @@ var TOOLS = [
2350
2543
  path: { type: "string", description: "path to the repo (default: the server's working directory)" },
2351
2544
  category: { enum: ["human", "with_ai", "ai"], description: "the honest authorship category (see the description). Defaults from with_ai if omitted." },
2352
2545
  with_ai: { type: "boolean", description: "legacy shorthand: true \u21D2 category with_ai, false \u21D2 human. Prefer `category`." },
2353
- tool: { type: "string", description: "the AI tool to name (Assisted-by for with_ai, Authored-by-ai for ai). Default: AI." }
2546
+ tool: { type: "string", description: "the AI tool to name (Assisted-by for with_ai, Authored-by-ai for ai). Default: AI." },
2547
+ 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." }
2354
2548
  }
2355
2549
  },
2356
2550
  handler: (args) => {
2357
2551
  const root = rootFor(args.path);
2358
2552
  const tool = args.tool;
2553
+ const model = args.model;
2359
2554
  const category = args.category;
2360
- if (category === "ai") return attestAiAuthorship(root, { tool });
2361
- return affirmAuthorship(root, { withAi: category === "with_ai" || args.with_ai === true, tool });
2555
+ if (category === "ai") return attestAiAuthorship(root, { tool, model });
2556
+ const withAi = category === "with_ai" || args.with_ai === true;
2557
+ return affirmAuthorship(root, { withAi, tool, model: withAi ? model : void 0 });
2362
2558
  }
2363
2559
  },
2364
2560
  {
@@ -2580,7 +2776,8 @@ function help() {
2580
2776
  Usage:
2581
2777
  madeby init Set MadeBy up: write .madeby/policy.json + the ACO AGENTS.md convention
2582
2778
  + the CI disclosure check. --update refreshes an outdated ACO block in
2583
- place; --required to enforce now; --host azure-devops for Azure Pipelines.
2779
+ place; --required to enforce now; --host azure-devops for Azure Pipelines;
2780
+ --record-model to also ask agents for the model version (opt-in).
2584
2781
  madeby check [<range>] The gate: do commits meet .madeby/policy.json? (a verdict \u2014 exit
2585
2782
  code gates). <range> e.g. origin/main..HEAD; in CI it's auto-detected.
2586
2783
  --json for a machine-readable result.
@@ -2595,6 +2792,7 @@ Usage:
2595
2792
  trailer \u2014 the symmetric human claim to \`ai\`).
2596
2793
  madeby me --with-ai [tool] Made by me, with AI: also disclose AI assistance (Assisted-by:
2597
2794
  <tool>, default "AI") \u2014 the assisted middle state, classified with_ai.
2795
+ --model <version> names the model too (Assisted-by: <tool>:<version>).
2598
2796
  madeby mcp Run the MCP server (stdio) \u2014 the tools, agent-callable.
2599
2797
  madeby help Show this help.
2600
2798
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "madeby",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
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",