@mnemom/mnemom 0.17.32-next.0 → 0.17.32-next.2

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.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * FIXTURE-ONLY statement source and card issuer for `mnemom agent share` (MNE-9037).
3
+ *
4
+ * Selected only when MNEMOM_AGENT_SHARE=1 AND MNEMOM_AGENT_SHARE_FIXTURE=1. It holds no key
5
+ * and signs nothing: the cards below are the two TEST cards committed in mnemom/aip
6
+ * (packages/verifier/test/fixtures/sharecard/{valid,period-revert}.json, key id
7
+ * test-sharecard-key-1) that the /share page's fixture verifier (MNE-9031) recognises. The
8
+ * issuer returns one of them only when the picked lines are exactly that card's lines; any
9
+ * other pick is an error. Two extra candidates (an estimate and an identifying label) exist so
10
+ * the refusal path can be shown. This is not a real issuer: a production source and signer
11
+ * are a separate, human-gated change.
12
+ */
13
+ import type { AgentShareDeps, CandidateLine, ShareCardLike } from "./agent-share.js";
14
+ export declare const AGENT_SHARE_FIXTURE_ENV = "MNEMOM_AGENT_SHARE_FIXTURE";
15
+ export declare function agentShareFixtureEnabled(env?: Record<string, string | undefined>): boolean;
16
+ export declare const FIXTURE_CARD_VALID: ShareCardLike;
17
+ export declare const FIXTURE_CARD_PERIOD_REVERT: ShareCardLike;
18
+ /** Real candidate lines plus two that must be refused. */
19
+ export declare const FIXTURE_CANDIDATES: CandidateLine[];
20
+ export declare const fixtureAgentShareParts: Pick<AgentShareDeps, "source" | "issuer">;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * FIXTURE-ONLY statement source and card issuer for `mnemom agent share` (MNE-9037).
3
+ *
4
+ * Selected only when MNEMOM_AGENT_SHARE=1 AND MNEMOM_AGENT_SHARE_FIXTURE=1. It holds no key
5
+ * and signs nothing: the cards below are the two TEST cards committed in mnemom/aip
6
+ * (packages/verifier/test/fixtures/sharecard/{valid,period-revert}.json, key id
7
+ * test-sharecard-key-1) that the /share page's fixture verifier (MNE-9031) recognises. The
8
+ * issuer returns one of them only when the picked lines are exactly that card's lines; any
9
+ * other pick is an error. Two extra candidates (an estimate and an identifying label) exist so
10
+ * the refusal path can be shown. This is not a real issuer: a production source and signer
11
+ * are a separate, human-gated change.
12
+ */
13
+ export const AGENT_SHARE_FIXTURE_ENV = "MNEMOM_AGENT_SHARE_FIXTURE";
14
+ export function agentShareFixtureEnabled(env = process.env) {
15
+ return env.MNEMOM_AGENT_SHARE === "1" && env[AGENT_SHARE_FIXTURE_ENV] === "1";
16
+ }
17
+ export const FIXTURE_CARD_VALID = {
18
+ typ: "AAP-ShareCard/v1",
19
+ card_id: "card-valid-0001",
20
+ period: {
21
+ start: "2026-10-01",
22
+ end: "2026-10-07",
23
+ },
24
+ issued_at: "2026-10-08T00:00:00.000Z",
25
+ signing_key_id: "test-sharecard-key-1",
26
+ statements: [
27
+ {
28
+ typ: "AAP-Statement/v1",
29
+ hash: "1111111111111111111111111111111111111111111111111111111111111111",
30
+ },
31
+ {
32
+ typ: "AAP-Statement/v1",
33
+ hash: "2222222222222222222222222222222222222222222222222222222222222222",
34
+ },
35
+ ],
36
+ lines: [
37
+ {
38
+ id: "l1",
39
+ kind: "count",
40
+ label: "Changes reviewed",
41
+ value: 42,
42
+ statement_hash: "1111111111111111111111111111111111111111111111111111111111111111",
43
+ },
44
+ {
45
+ id: "l2",
46
+ kind: "count",
47
+ label: "Checks passed",
48
+ value: 40,
49
+ statement_hash: "2222222222222222222222222222222222222222222222222222222222222222",
50
+ },
51
+ ],
52
+ signature: "qsHSChAxBEnEyNoQQLQMFXZ6LKxruMyijxA5s3h9oPKl4OijJqxFm3QC0rteDXJsYwJCMeKyk_0xzGTDc3wDAQ",
53
+ };
54
+ export const FIXTURE_CARD_PERIOD_REVERT = {
55
+ typ: "AAP-ShareCard/v1",
56
+ card_id: "card-period-0001",
57
+ period: {
58
+ start: "2026-10-01",
59
+ end: "2026-10-07",
60
+ },
61
+ issued_at: "2026-10-08T00:00:00.000Z",
62
+ signing_key_id: "test-sharecard-key-1",
63
+ statements: [
64
+ {
65
+ typ: "AAP-Statement/v1",
66
+ hash: "1111111111111111111111111111111111111111111111111111111111111111",
67
+ },
68
+ {
69
+ typ: "AAP-Statement/v1",
70
+ hash: "2222222222222222222222222222222222222222222222222222222222222222",
71
+ },
72
+ {
73
+ typ: "AAP-Statement/v1",
74
+ hash: "3333333333333333333333333333333333333333333333333333333333333333",
75
+ },
76
+ ],
77
+ lines: [
78
+ {
79
+ id: "l1",
80
+ kind: "count",
81
+ label: "Changes reviewed",
82
+ value: 42,
83
+ statement_hash: "1111111111111111111111111111111111111111111111111111111111111111",
84
+ },
85
+ {
86
+ id: "l2",
87
+ kind: "count",
88
+ label: "Checks passed",
89
+ value: 40,
90
+ statement_hash: "2222222222222222222222222222222222222222222222222222222222222222",
91
+ },
92
+ {
93
+ id: "l3",
94
+ kind: "revert",
95
+ label: "Changes reverted",
96
+ value: 2,
97
+ statement_hash: "3333333333333333333333333333333333333333333333333333333333333333",
98
+ },
99
+ ],
100
+ signature: "OQf-4xgUQbIxOef_BsJeTehfpqLPPR2GTv8_y6EbxPX9UkFp2PpE2pnETh2VqcwDfV1U_4bSR8Pwrpsch3DwCw",
101
+ };
102
+ const FIXTURE_CARDS = [FIXTURE_CARD_VALID, FIXTURE_CARD_PERIOD_REVERT];
103
+ const HASH_E = "e".repeat(64);
104
+ const HASH_F = "f".repeat(64);
105
+ /** Real candidate lines plus two that must be refused. */
106
+ export const FIXTURE_CANDIDATES = [
107
+ ...FIXTURE_CARD_PERIOD_REVERT.lines,
108
+ {
109
+ id: "e1",
110
+ kind: "count",
111
+ label: "Hours saved",
112
+ value: 12,
113
+ statement_hash: HASH_E,
114
+ estimated: true,
115
+ },
116
+ { id: "r1", kind: "count", label: "Repo mnemom/aip", value: 3, statement_hash: HASH_F },
117
+ ];
118
+ const same = (a, b) => a.length === b.length &&
119
+ a.every((l) => {
120
+ const m = b.find((x) => x.id === l.id);
121
+ return m !== undefined && JSON.stringify(m) === JSON.stringify(l);
122
+ });
123
+ export const fixtureAgentShareParts = {
124
+ source: { listLines: async () => FIXTURE_CANDIDATES },
125
+ issuer: {
126
+ issue: async (lines) => {
127
+ const card = FIXTURE_CARDS.find((c) => same(c.lines, lines));
128
+ if (!card) {
129
+ throw new Error("fixture issuer only issues the two test cards: pick l1,l2 or l1,l2,l3");
130
+ }
131
+ return structuredClone(card);
132
+ },
133
+ },
134
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `mnemom agent share` (MNE-9037, parent MNE-9014) — build a shareable work card from the
3
+ * lines of your own signed statements and print a link to the static share page with the
4
+ * card in the URL fragment (`/share#card=<base64url(JSON)>`). The fragment is never sent to
5
+ * a server. The command only PRINTS the link: it never posts, uploads or opens it anywhere
6
+ * (W6.2 — the share offer never auto-posts).
7
+ *
8
+ * Gated by MNEMOM_AGENT_SHARE=1 (not experimental-registry; off by default, so default CLI
9
+ * behaviour is unchanged). Everything outside the process is injected:
10
+ * - `source` lists candidate lines from the user's signed statements,
11
+ * - `issuer` returns the SIGNED AAP-ShareCard/v1 for the chosen lines (the CLI never holds
12
+ * a signing key),
13
+ * - `renderer` the aip `renderLines` (MNE-9026, unpublished) behind a small interface.
14
+ * The CLI refuses, before anything is issued, lines that are estimates or that carry
15
+ * identifying fields, and re-checks the returned card for the same.
16
+ */
17
+ export declare const AGENT_SHARE_ENV = "MNEMOM_AGENT_SHARE";
18
+ export declare function agentShareEnabled(env?: Record<string, string | undefined>): boolean;
19
+ /** A line as offered by the statement source. Extra keys are kept so they can be refused. */
20
+ export interface CandidateLine {
21
+ id: string;
22
+ kind: string;
23
+ label: string;
24
+ value: number;
25
+ statement_hash: string;
26
+ [extra: string]: unknown;
27
+ }
28
+ export interface SharedCardLine {
29
+ id: string;
30
+ kind: "count" | "revert";
31
+ label: string;
32
+ value: number;
33
+ statement_hash: string;
34
+ }
35
+ /** Shape of AAP-ShareCard/v1 as far as the CLI needs it (full verification is the page's job). */
36
+ export interface ShareCardLike {
37
+ typ: string;
38
+ lines: SharedCardLine[];
39
+ [key: string]: unknown;
40
+ }
41
+ export interface RenderedLinePreview {
42
+ text: string;
43
+ coverage?: string;
44
+ kind: string;
45
+ }
46
+ /** The aip `renderLines` contract, injected (not yet published). */
47
+ export interface LineRenderer {
48
+ renderLines(card: unknown): RenderedLinePreview[];
49
+ }
50
+ export interface StatementSource {
51
+ listLines(): Promise<CandidateLine[]>;
52
+ }
53
+ export interface CardIssuer {
54
+ /** Signed card committing to exactly these lines. */
55
+ issue(lines: SharedCardLine[]): Promise<ShareCardLike>;
56
+ }
57
+ export interface AgentShareDeps {
58
+ source: StatementSource;
59
+ issuer: CardIssuer;
60
+ renderer: LineRenderer;
61
+ /** Interactive picker: returns the chosen line ids. */
62
+ pick: (lines: SharedCardLine[]) => Promise<string[]>;
63
+ isTTY: () => boolean;
64
+ websiteBase: () => string;
65
+ out: (line: string) => void;
66
+ err: (line: string) => void;
67
+ }
68
+ export interface AgentShareFlags {
69
+ lines?: string;
70
+ list?: boolean;
71
+ json?: boolean;
72
+ }
73
+ export type RefusalReason = "estimate" | "identifying" | "malformed";
74
+ export interface Refusal {
75
+ id: string;
76
+ reason: RefusalReason;
77
+ detail: string;
78
+ }
79
+ /** Null when the line may be shared; otherwise why it is refused. Fails closed. */
80
+ export declare function refuseReason(line: CandidateLine): Refusal | null;
81
+ export declare function toSharedLine(line: CandidateLine): SharedCardLine;
82
+ /** Card → `<base>/share#card=<base64url(UTF-8 JSON)>` (the format the share page reads). */
83
+ export declare function buildShareLink(card: unknown, websiteBase: string): string;
84
+ /** Inverse of buildShareLink's fragment, for tests and `--json` consumers. */
85
+ export declare function decodeShareFragment(link: string): unknown;
86
+ export declare function agentShareCommand(flags: AgentShareFlags, deps: AgentShareDeps, env?: Record<string, string | undefined>): Promise<number>;
87
+ export declare const liveAgentShareDeps: AgentShareDeps;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `mnemom agent share` (MNE-9037, parent MNE-9014) — build a shareable work card from the
3
+ * lines of your own signed statements and print a link to the static share page with the
4
+ * card in the URL fragment (`/share#card=<base64url(JSON)>`). The fragment is never sent to
5
+ * a server. The command only PRINTS the link: it never posts, uploads or opens it anywhere
6
+ * (W6.2 — the share offer never auto-posts).
7
+ *
8
+ * Gated by MNEMOM_AGENT_SHARE=1 (not experimental-registry; off by default, so default CLI
9
+ * behaviour is unchanged). Everything outside the process is injected:
10
+ * - `source` lists candidate lines from the user's signed statements,
11
+ * - `issuer` returns the SIGNED AAP-ShareCard/v1 for the chosen lines (the CLI never holds
12
+ * a signing key),
13
+ * - `renderer` the aip `renderLines` (MNE-9026, unpublished) behind a small interface.
14
+ * The CLI refuses, before anything is issued, lines that are estimates or that carry
15
+ * identifying fields, and re-checks the returned card for the same.
16
+ */
17
+ import { createInterface } from "node:readline/promises";
18
+ import { getWebsiteUrl } from "../lib/config.js";
19
+ import { fmt } from "../lib/format.js";
20
+ export const AGENT_SHARE_ENV = "MNEMOM_AGENT_SHARE";
21
+ export function agentShareEnabled(env = process.env) {
22
+ return env[AGENT_SHARE_ENV] === "1";
23
+ }
24
+ const HEX64 = /^[0-9a-f]{64}$/;
25
+ const OPAQUE_ID = /^[a-z0-9_-]{1,64}$/;
26
+ /** Same charset the verifier enforces on labels. */
27
+ const LABEL = /^[A-Za-z0-9 .,()+-]{1,40}$/;
28
+ /** The only top-level fields an AAP-ShareCard/v1 may carry. */
29
+ const CARD_KEYS = [
30
+ "typ",
31
+ "card_id",
32
+ "period",
33
+ "issued_at",
34
+ "signing_key_id",
35
+ "statements",
36
+ "lines",
37
+ "signature",
38
+ ];
39
+ const LINE_KEYS = ["id", "kind", "label", "value", "statement_hash"];
40
+ /** Words that mark a label as naming a repo, org, person, tool, code or goal text. */
41
+ const IDENTIFYING_LABEL = /\b(repo|repository|org|organization|organisation|team|person|user|author|tool|goal|code|branch|commit|pr)\b/i;
42
+ /** Null when the line may be shared; otherwise why it is refused. Fails closed. */
43
+ export function refuseReason(line) {
44
+ const id = typeof line.id === "string" ? line.id : "(unknown)";
45
+ const no = (reason, detail) => ({ id, reason, detail });
46
+ if (line.estimated === true || line.estimate === true || line.provenance === "estimate") {
47
+ return no("estimate", "it is an estimate, not a count from a signed statement");
48
+ }
49
+ const extras = Object.keys(line).filter((k) => !LINE_KEYS.includes(k));
50
+ if (extras.length > 0) {
51
+ return no("identifying", `it carries extra field(s): ${extras.join(", ")}`);
52
+ }
53
+ if (typeof line.id !== "string" || !OPAQUE_ID.test(line.id))
54
+ return no("malformed", "bad line id");
55
+ if (line.kind !== "count" && line.kind !== "revert")
56
+ return no("malformed", "unsupported line kind");
57
+ if (typeof line.value !== "number" || !Number.isSafeInteger(line.value) || line.value < 0) {
58
+ return no("estimate", "its value is not an exact non-negative integer");
59
+ }
60
+ if (typeof line.statement_hash !== "string" || !HEX64.test(line.statement_hash)) {
61
+ return no("estimate", "it is not tied to a signed statement");
62
+ }
63
+ if (typeof line.label !== "string" ||
64
+ !LABEL.test(line.label) ||
65
+ /[/#]|:\/\/|www\./i.test(line.label)) {
66
+ return no("identifying", "its label is not short plain text");
67
+ }
68
+ if (IDENTIFYING_LABEL.test(line.label)) {
69
+ return no("identifying", "its label names a repo, org, person, tool, code or goal");
70
+ }
71
+ return null;
72
+ }
73
+ export function toSharedLine(line) {
74
+ return {
75
+ id: line.id,
76
+ kind: line.kind,
77
+ label: line.label,
78
+ value: line.value,
79
+ statement_hash: line.statement_hash,
80
+ };
81
+ }
82
+ /** Card → `<base>/share#card=<base64url(UTF-8 JSON)>` (the format the share page reads). */
83
+ export function buildShareLink(card, websiteBase) {
84
+ const bytes = new TextEncoder().encode(JSON.stringify(card));
85
+ const b64 = Buffer.from(bytes).toString("base64url");
86
+ return `${websiteBase.replace(/\/+$/, "")}/share#card=${b64}`;
87
+ }
88
+ /** Inverse of buildShareLink's fragment, for tests and `--json` consumers. */
89
+ export function decodeShareFragment(link) {
90
+ const hash = link.slice(link.indexOf("#") + 1);
91
+ const encoded = new URLSearchParams(hash).get("card") ?? "";
92
+ try {
93
+ return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** The issued card must contain exactly the chosen lines, unaltered. */
100
+ function checkIssuedCard(card, chosen) {
101
+ if (!card || typeof card !== "object" || !Array.isArray(card.lines))
102
+ return "the card has no lines";
103
+ const extra = Object.keys(card).filter((k) => !CARD_KEYS.includes(k));
104
+ if (extra.length > 0)
105
+ return `the card carries unexpected field(s): ${extra.join(", ")}`;
106
+ if (card.lines.length !== chosen.length)
107
+ return "the card's lines differ from the ones you picked";
108
+ for (const want of chosen) {
109
+ const got = card.lines.find((l) => l.id === want.id);
110
+ if (!got ||
111
+ LINE_KEYS.some((k) => got[k] !== want[k]) ||
112
+ Object.keys(got).length !== LINE_KEYS.length) {
113
+ return `the card's line "${want.id}" differs from the one you picked`;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ export async function agentShareCommand(flags, deps, env = process.env) {
119
+ if (!agentShareEnabled(env)) {
120
+ deps.err(fmt.error("unknown command 'share' for 'mnemom agent'"));
121
+ return 1;
122
+ }
123
+ let candidates;
124
+ try {
125
+ candidates = await deps.source.listLines();
126
+ }
127
+ catch (error) {
128
+ deps.err(fmt.error(`Could not read your signed statements: ${error.message}`));
129
+ return 1;
130
+ }
131
+ const offered = [];
132
+ const refused = [];
133
+ for (const c of candidates) {
134
+ const r = refuseReason(c);
135
+ if (r)
136
+ refused.push(r);
137
+ else
138
+ offered.push(toSharedLine(c));
139
+ }
140
+ if (flags.list) {
141
+ if (flags.json) {
142
+ deps.out(JSON.stringify({ lines: offered, refused }, null, 2));
143
+ }
144
+ else {
145
+ if (offered.length === 0)
146
+ deps.out("No lines available to share.");
147
+ for (const l of offered)
148
+ deps.out(`${l.id} ${l.label}: ${l.value}`);
149
+ for (const r of refused)
150
+ deps.out(`(not shareable) ${r.id}: ${r.detail}`);
151
+ }
152
+ return 0;
153
+ }
154
+ let ids;
155
+ if (flags.lines !== undefined) {
156
+ ids = [
157
+ ...new Set(flags.lines
158
+ .split(",")
159
+ .map((s) => s.trim())
160
+ .filter(Boolean)),
161
+ ];
162
+ }
163
+ else if (deps.isTTY()) {
164
+ if (offered.length === 0) {
165
+ deps.err(fmt.error("No lines available to share."));
166
+ return 1;
167
+ }
168
+ ids = await deps.pick(offered);
169
+ }
170
+ else {
171
+ deps.err(fmt.error("No terminal to pick lines in. Pass --lines <id,id> (see --list)."));
172
+ return 1;
173
+ }
174
+ if (ids.length === 0) {
175
+ deps.err(fmt.error("No lines picked."));
176
+ return 1;
177
+ }
178
+ const chosen = [];
179
+ for (const id of ids) {
180
+ const r = refused.find((x) => x.id === id);
181
+ if (r) {
182
+ deps.err(fmt.error(`Refusing line "${id}": ${r.detail}.`));
183
+ return 1;
184
+ }
185
+ const line = offered.find((l) => l.id === id);
186
+ if (!line) {
187
+ deps.err(fmt.error(`Unknown line "${id}". Run with --list to see what is available.`));
188
+ return 1;
189
+ }
190
+ chosen.push(line);
191
+ }
192
+ let card;
193
+ try {
194
+ card = await deps.issuer.issue(chosen);
195
+ }
196
+ catch (error) {
197
+ deps.err(fmt.error(`Could not build the card: ${error.message}`));
198
+ return 1;
199
+ }
200
+ const problem = checkIssuedCard(card, chosen);
201
+ if (problem) {
202
+ deps.err(fmt.error(`Refusing to share: ${problem}.`));
203
+ return 1;
204
+ }
205
+ const link = buildShareLink(card, deps.websiteBase());
206
+ if (flags.json) {
207
+ deps.out(JSON.stringify({ link, lines: chosen }, null, 2));
208
+ return 0;
209
+ }
210
+ deps.out("Your card:");
211
+ for (const r of deps.renderer.renderLines(card)) {
212
+ deps.out(` ${r.text}${r.coverage ? ` (${r.coverage})` : ""}`);
213
+ }
214
+ deps.out("");
215
+ deps.out(link);
216
+ deps.out("");
217
+ deps.out("Nothing was posted or sent. The card is in the link's fragment, which a browser never sends to a server; share the link yourself if you want to.");
218
+ return 0;
219
+ }
220
+ /** Not wired to a backend yet: the signed-statement source and card issuer land with MNE-9026. */
221
+ const notConnected = () => {
222
+ throw new Error("statement source not connected yet (waiting on the card renderer, MNE-9026)");
223
+ };
224
+ export const liveAgentShareDeps = {
225
+ source: { listLines: async () => notConnected() },
226
+ issuer: { issue: async () => notConnected() },
227
+ renderer: {
228
+ renderLines: (card) => (card.lines ?? []).map((l) => ({
229
+ text: `${l.label}: ${l.value}`,
230
+ kind: l.kind,
231
+ })),
232
+ },
233
+ pick: async (lines) => {
234
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
235
+ try {
236
+ lines.forEach((l, i) => process.stderr.write(`${i + 1}. ${l.label}: ${l.value}\n`));
237
+ const answer = await rl.question("Pick lines (numbers, comma-separated): ");
238
+ return answer
239
+ .split(",")
240
+ .map((s) => lines[Number(s.trim()) - 1]?.id)
241
+ .filter((x) => typeof x === "string");
242
+ }
243
+ finally {
244
+ rl.close();
245
+ }
246
+ },
247
+ isTTY: () => Boolean(process.stdin.isTTY),
248
+ websiteBase: getWebsiteUrl,
249
+ out: (line) => console.log(line),
250
+ err: (line) => console.error(line),
251
+ };
@@ -30,7 +30,7 @@ import { type SessionRecord } from "../lib/agent-ledger.js";
30
30
  import { type ExperimentalId } from "../lib/experimental.js";
31
31
  import { conductReceiptFor, type ReceiptLaunch } from "../lib/conduct-receipt.js";
32
32
  import { type GovernanceOptIn } from "./agent-setup.js";
33
- import { type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
33
+ import { type CcSettingsView, type ContextProfile, type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
34
34
  export interface AgentOptions {
35
35
  goal?: string;
36
36
  requirement?: string[];
@@ -311,6 +311,28 @@ export interface RcCapability {
311
311
  * and by the auto-default selection (a bare launch prefers RC when capable).
312
312
  */
313
313
  export declare function checkRcCapability(cli: ResolvedCli): RcCapability;
314
+ /**
315
+ * The extra `--settings` the launch merges (beside the autocompact guard): the status line's,
316
+ * and with commit reporting on (W1.8) the `Mnemom-Conversation` commit trailer, which keeps the
317
+ * user's own commit attribution in front of it. Never when the user passed their own --settings.
318
+ */
319
+ export declare function launchSettingsExtra(o: {
320
+ statusline: Record<string, unknown> | null;
321
+ commitReport: boolean;
322
+ ownSettings: boolean;
323
+ conversationId: string;
324
+ /** Claude Code's settings files, highest precedence first (readCcSettings). */
325
+ settings: CcSettingsView[];
326
+ }): Record<string, unknown> | null;
327
+ /**
328
+ * Whether the local base-URL proxy runs: implicit goals (turn header), the conduct receipt and
329
+ * commit reporting (W1.8) all need it; commit reporting reads the lineage key it records.
330
+ */
331
+ export declare function proxyWanted(o: {
332
+ turnHeader: boolean;
333
+ receipt: boolean;
334
+ commitReport: boolean;
335
+ }): boolean;
314
336
  /**
315
337
  * Whether the proxy captures the gateway's record state: the conduct receipt reads it at exit,
316
338
  * and the fed status line reads its lineage key while the session runs.
@@ -345,6 +367,15 @@ export declare function prepareStatuslineFeed(o: {
345
367
  }): {
346
368
  feedFile: string;
347
369
  } | null;
370
+ /**
371
+ * Whether the launch reads or writes Record, and so needs the served agent's org: the conduct
372
+ * receipt, commit reporting and the fed status line each key on it (lib/record-org).
373
+ */
374
+ export declare function recordOrgWanted(o: {
375
+ receipt: boolean;
376
+ commitReport: boolean;
377
+ fedStatusline: boolean;
378
+ }): boolean;
348
379
  /**
349
380
  * Whether this launch feeds the status line measured model cost and μ (W1.4): prerelease
350
381
  * builds only (stable stays "mnemom", decision 14), and only when the turn proxy runs, since
@@ -34,9 +34,11 @@ import { fileURLToPath } from "node:url";
34
34
  import { FEED_ENV, statuslineLaunch } from "../lib/statusline.js";
35
35
  import { startFeed } from "../lib/statusline-feed.js";
36
36
  import { isPrerelease } from "../lib/update/semver.js";
37
+ import { attributionSettings, COMMIT_REPORT_ENV, commitReportEnabled, realGit, startCommitReporter, } from "../lib/commit-report.js";
37
38
  import { ALLOW_AUTOCOMPACT_OFF_ENV, dropAutocompactOffEnv, hasOwnSettingsFlag, mergedSettingsArgs, planAutocompactGuard, } from "../lib/autocompact-guard.js";
38
39
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
39
40
  import { createApiKey } from "../lib/api.js";
41
+ import { recordOrgFor } from "../lib/record-org.js";
40
42
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, readMnemomKey, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
41
43
  import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, resumedContextProfile, toLedgerContract, } from "../lib/agent-ledger.js";
42
44
  import { isLoggedIn } from "../lib/auth.js";
@@ -705,6 +707,16 @@ export async function agentCommand(scenarioArg, options = {}) {
705
707
  }
706
708
  }
707
709
  if (shape === "terminal") {
710
+ // Record keys the lineage under the served agent's org, not the account org (lib/record-org).
711
+ const recordOrgId = recordOrgWanted({
712
+ receipt: experimental.includes("record"),
713
+ commitReport: cli.name === "claude" && commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
714
+ fedStatusline: cli.name === "claude" && fedStatuslineWanted(CLI_VERSION, Boolean(goalMode), process.env),
715
+ }) && account.orgId
716
+ ? await recordOrgFor(servedAgentId, account.orgId, {
717
+ onFallback: (reason) => say(fmt.warn(`mnemom agent: record: could not read ${servedAgentId}'s org (${reason}); using ${account.orgId}, which may hold none of this session's record.`)),
718
+ })
719
+ : account.orgId;
708
720
  launchTerminal({
709
721
  cli,
710
722
  door,
@@ -734,9 +746,12 @@ export async function agentCommand(scenarioArg, options = {}) {
734
746
  knownCcSessionId: cc.id ?? continuedCcSessionId,
735
747
  plugin,
736
748
  receipt: experimental.includes("record")
737
- ? { orgId: account.orgId, agentId: servedAgentId }
749
+ ? { orgId: recordOrgId, agentId: servedAgentId }
738
750
  : undefined,
739
- orgId: account.orgId,
751
+ orgId: recordOrgId,
752
+ commitReport: cli.name === "claude" &&
753
+ Boolean(account.orgId) &&
754
+ commitReportEnabled(process.env[COMMIT_REPORT_ENV], CLI_VERSION),
740
755
  });
741
756
  return; // launchTerminal exits the process
742
757
  }
@@ -1292,9 +1307,18 @@ function launchTerminal(ctx) {
1292
1307
  env: process.env,
1293
1308
  childEnv,
1294
1309
  });
1295
- // Autocompact guard (lib/autocompact-guard.ts): one --settings carries both layers.
1296
- const guard = applyAutocompactGuard(childEnv, ctx.options, !hasOwnSettingsFlag(passthrough));
1297
- args.push(...mergedSettingsArgs(guard, sl?.settings ?? null));
1310
+ // Autocompact guard (lib/autocompact-guard.ts): one --settings carries every layer. The
1311
+ // commit trailer (W1.8) rides in it unless the user passed their own --settings.
1312
+ const ownSettings = hasOwnSettingsFlag(passthrough);
1313
+ const guard = applyAutocompactGuard(childEnv, ctx.options, !ownSettings);
1314
+ const extra = launchSettingsExtra({
1315
+ statusline: sl?.settings ?? null,
1316
+ commitReport: Boolean(ctx.commitReport),
1317
+ ownSettings,
1318
+ conversationId: ctx.conversationId,
1319
+ settings: ctx.commitReport && !ownSettings ? ccSettings() : [],
1320
+ });
1321
+ args.push(...mergedSettingsArgs(guard, extra));
1298
1322
  }
1299
1323
  if (ctx.contextHint) {
1300
1324
  seedContextHintFlag(childEnv);
@@ -1303,7 +1327,17 @@ function launchTerminal(ctx) {
1303
1327
  }
1304
1328
  let recordStatePath;
1305
1329
  const run = () => {
1330
+ const launchedSec = Math.floor(Date.now() / 1000);
1306
1331
  const child = spawn(ctx.cli.bin, args, { stdio: "inherit", env: childEnv });
1332
+ // W1.8: report the session's commits while it runs; no record state (no proxy) → no reports.
1333
+ const commits = ctx.commitReport && ctx.orgId && recordStatePath
1334
+ ? startCommitReporter({
1335
+ orgId: ctx.orgId,
1336
+ recordStateFile: recordStatePath,
1337
+ sinceSec: launchedSec,
1338
+ git: realGit(process.cwd()),
1339
+ })
1340
+ : null;
1307
1341
  child.on("error", (err) => {
1308
1342
  say(`mnemom agent: failed to launch ${ctx.cli.name}: ${err.message}`);
1309
1343
  process.exit(1);
@@ -1313,10 +1347,12 @@ function launchTerminal(ctx) {
1313
1347
  process.kill(process.pid, signal);
1314
1348
  else
1315
1349
  void (async () => {
1316
- // The conduct receipt never blocks exit (a hard deadline inside) and never changes
1317
- // the exit code.
1350
+ // The conduct receipt and the last commit sweep run side by side, each under its own
1351
+ // hard deadline inside W1.3's exit budget; neither changes the exit code.
1352
+ const swept = commits?.finish().catch(() => undefined);
1318
1353
  if (ctx.receipt)
1319
1354
  await sayConductReceipt({ ...ctx.receipt, statePath: recordStatePath });
1355
+ await swept;
1320
1356
  // Claude Code's own last line is "Resume this session with: claude --resume <id>",
1321
1357
  // which reopens it OUTSIDE Mnemom (ungoverned, unbilled). Ours goes after it, last.
1322
1358
  if (code === 0 && ctx.resumeHint && wantsResumeHint(ctx.cli, args)) {
@@ -1334,7 +1370,11 @@ function launchTerminal(ctx) {
1334
1370
  // implicit), to capture the lineage key the conduct receipt reads. Fail-open: no proxy → the
1335
1371
  // door directly, as before, and a receipt that links instead of giving figures.
1336
1372
  const turnHeader = Boolean(ctx.goalMode) && turnProxyWanted(process.env);
1337
- if (!turnHeader && !ctx.receipt) {
1373
+ if (!proxyWanted({
1374
+ turnHeader,
1375
+ receipt: Boolean(ctx.receipt),
1376
+ commitReport: Boolean(ctx.commitReport),
1377
+ })) {
1338
1378
  run();
1339
1379
  return;
1340
1380
  }
@@ -1343,7 +1383,30 @@ function launchTerminal(ctx) {
1343
1383
  recordStatePath = statePath;
1344
1384
  onBaseUrlProxyReady(baseUrl, statePath, childEnv, feed && ctx.orgId ? { ...feed, orgId: ctx.orgId } : null);
1345
1385
  run();
1346
- }, { turnHeader, recordState: wantsRecordState(Boolean(ctx.receipt), feed !== null) });
1386
+ }, {
1387
+ turnHeader,
1388
+ recordState: wantsRecordState(Boolean(ctx.receipt || ctx.commitReport), feed !== null),
1389
+ });
1390
+ }
1391
+ /**
1392
+ * The extra `--settings` the launch merges (beside the autocompact guard): the status line's,
1393
+ * and with commit reporting on (W1.8) the `Mnemom-Conversation` commit trailer, which keeps the
1394
+ * user's own commit attribution in front of it. Never when the user passed their own --settings.
1395
+ */
1396
+ export function launchSettingsExtra(o) {
1397
+ const trailer = o.commitReport && !o.ownSettings
1398
+ ? attributionSettings(o.conversationId, o.settings.find((v) => v.commitAttribution !== undefined)?.commitAttribution)
1399
+ : null;
1400
+ if (!o.statusline && !trailer)
1401
+ return null;
1402
+ return { ...(o.statusline ?? {}), ...(trailer ?? {}) };
1403
+ }
1404
+ /**
1405
+ * Whether the local base-URL proxy runs: implicit goals (turn header), the conduct receipt and
1406
+ * commit reporting (W1.8) all need it; commit reporting reads the lineage key it records.
1407
+ */
1408
+ export function proxyWanted(o) {
1409
+ return o.turnHeader || o.receipt || o.commitReport;
1347
1410
  }
1348
1411
  /**
1349
1412
  * Whether the proxy captures the gateway's record state: the conduct receipt reads it at exit,
@@ -1376,6 +1439,13 @@ export function prepareStatuslineFeed(o) {
1376
1439
  o.childEnv[FEED_ENV] = feed.feedFile;
1377
1440
  return feed;
1378
1441
  }
1442
+ /**
1443
+ * Whether the launch reads or writes Record, and so needs the served agent's org: the conduct
1444
+ * receipt, commit reporting and the fed status line each key on it (lib/record-org).
1445
+ */
1446
+ export function recordOrgWanted(o) {
1447
+ return o.receipt || o.commitReport || o.fedStatusline;
1448
+ }
1379
1449
  /**
1380
1450
  * Whether this launch feeds the status line measured model cost and μ (W1.4): prerelease
1381
1451
  * builds only (stable stays "mnemom", decision 14), and only when the turn proxy runs, since
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { policyInitCommand, policyValidateCommand, policyPublishCommand, policyL
14
14
  import { protectionShowCommand, protectionPublishCommand, protectionValidateCommand, protectionEditCommand, protectionDriftCommand, } from "./commands/protection.js";
15
15
  import { agentsListCommand, agentsClaimCommand, agentsMoveCommand } from "./commands/agents.js";
16
16
  import { agentExportCommand, liveExportDeps } from "./commands/agent-export.js";
17
+ import { agentShareCommand, liveAgentShareDeps } from "./commands/agent-share.js";
18
+ import { agentShareFixtureEnabled, fixtureAgentShareParts, } from "./commands/agent-share-fixture.js";
17
19
  import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
18
20
  import { agentGoalPinCommand, agentGoalResetCommand, agentGoalShowCommand, liveGoalDeps, } from "./commands/agent-goal.js";
19
21
  import { agentFuelCommand, liveFuelDeps } from "./commands/agent-fuel.js";
@@ -911,6 +913,21 @@ agentCmd
911
913
  .action(async (idOrName, opts) => {
912
914
  process.exitCode = await agentExportCommand(idOrName, opts, liveExportDeps);
913
915
  });
916
+ // Hidden until MNEMOM_AGENT_SHARE=1 (MNE-9037): with the flag off the command behaves as unknown.
917
+ agentCmd
918
+ .command("share", { hidden: true })
919
+ .description("Build a shareable card from lines of your signed statements and print the share-page link (card in the URL fragment; nothing is posted). Needs MNEMOM_AGENT_SHARE=1")
920
+ .option("--lines <ids>", "Comma-separated line ids to include (skips the picker)")
921
+ .option("--list", "List the lines available to share and exit")
922
+ .option("--json", "Print JSON instead of text")
923
+ .action(async (opts) => {
924
+ // MNEMOM_AGENT_SHARE_FIXTURE=1 (with MNEMOM_AGENT_SHARE=1) swaps in the test-only fixture
925
+ // source/issuer; otherwise the not-yet-connected live deps are used, unchanged.
926
+ const deps = agentShareFixtureEnabled()
927
+ ? { ...liveAgentShareDeps, ...fixtureAgentShareParts }
928
+ : liveAgentShareDeps;
929
+ process.exitCode = await agentShareCommand(opts, deps);
930
+ });
914
931
  agentCmd
915
932
  .command("sessions")
916
933
  .description("List your Mnemom Agent sessions from the local ledger (~/.mnemom/sessions.jsonl), newest first — the last 20 unless --all")
@@ -269,6 +269,8 @@ export declare function supportsOneM(model: string): boolean;
269
269
  export interface CcSettingsView {
270
270
  model?: string;
271
271
  env?: Record<string, string | undefined>;
272
+ /** `attribution.commit`, or false when `attribution` is false (all attribution hidden). */
273
+ commitAttribution?: string | false;
272
274
  }
273
275
  /**
274
276
  * Claude Code's settings files, highest precedence first: `<cwd>/.claude/settings.local.json`,
package/dist/lib/agent.js CHANGED
@@ -314,6 +314,13 @@ export function readCcSettings(opts) {
314
314
  const view = {};
315
315
  if (typeof o.model === "string")
316
316
  view.model = o.model;
317
+ if (o.attribution === false)
318
+ view.commitAttribution = false;
319
+ else if (o.attribution && typeof o.attribution === "object") {
320
+ const commit = o.attribution.commit;
321
+ if (typeof commit === "string")
322
+ view.commitAttribution = commit;
323
+ }
317
324
  if (o.env && typeof o.env === "object") {
318
325
  view.env = Object.fromEntries(Object.entries(o.env).filter((e) => typeof e[1] === "string"));
319
326
  }
package/dist/lib/api.d.ts CHANGED
@@ -450,6 +450,19 @@ export interface RecordTotals {
450
450
  row_count: number;
451
451
  }
452
452
  export declare function getRecordTotals(orgId: string, lineageKey: string, signal?: AbortSignal): Promise<RecordTotals>;
453
+ /**
454
+ * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
455
+ * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
456
+ * status, never throws on a non-2xx: the caller fails open on every outcome.
457
+ */
458
+ export declare function postRecordCommit(orgId: string, body: {
459
+ schema: "RecordCommit/v1";
460
+ lineage_key: string;
461
+ repo: string;
462
+ branch: string;
463
+ sha: string;
464
+ patch_id: string;
465
+ }, signal?: AbortSignal): Promise<number>;
453
466
  export declare function getMuBalance(orgId?: string): Promise<MuBalance>;
454
467
  /**
455
468
  * Mint the one-time `cards:write:protection` capability for an agent — the
package/dist/lib/api.js CHANGED
@@ -411,6 +411,25 @@ export async function getMe() {
411
411
  export async function getRecordTotals(orgId, lineageKey, signal) {
412
412
  return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/record/totals?lineage_key=${encodeURIComponent(lineageKey)}`, signal ? { signal } : {}, { notFoundLabel: "Record totals", fallback: "Could not read record totals" });
413
413
  }
414
+ /**
415
+ * Report one commit to Record's commit index (Doors W1.8, lib/commit-report.ts):
416
+ * `POST /v1/orgs/{org}/record/commits`, body RecordCommit/v1. Returns the HTTP
417
+ * status, never throws on a non-2xx: the caller fails open on every outcome.
418
+ */
419
+ export async function postRecordCommit(orgId, body, signal) {
420
+ const url = validateUrl(`${API_BASE}/v1/orgs/${encodeURIComponent(orgId)}/record/commits`);
421
+ const response = await fetchWithAuthRetry(url, async () => ({
422
+ method: "POST",
423
+ headers: {
424
+ ...(await authHeaders()),
425
+ Accept: "application/json",
426
+ "Content-Type": "application/json",
427
+ },
428
+ body: JSON.stringify(body),
429
+ ...(signal ? { signal } : {}),
430
+ }));
431
+ return response.status;
432
+ }
414
433
  export async function getMuBalance(orgId) {
415
434
  const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
416
435
  return gFetch(`/v1/billing/mu${q}`, {}, { fallback: "Could not read MU balance" });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Door one's commit reporting (Doors W1.8, OD-3, OD-5): the launcher adds the
3
+ * `Mnemom-Conversation` trailer and reports every commit the session makes to
4
+ * Record's commit index, so the PR record reads "N of M commits bound" with N
5
+ * equal to M.
6
+ *
7
+ * TRAILER. Claude Code writes `attribution.commit` into each commit it makes; the
8
+ * launcher passes it through `--settings` (OD-3). The model writes it, so it is
9
+ * best-effort; the commit index is the proof. The user's own commit attribution
10
+ * (or Claude Code's default Co-Authored-By line) is kept in front of ours, because
11
+ * a `--settings` value replaces theirs.
12
+ *
13
+ * INDEX. While Claude Code runs, the launcher looks for commits made in this
14
+ * repository since the launch, on local branches, by this repository's git user,
15
+ * and POSTs each one (sha, `git patch-id --stable`, branch, repo) to
16
+ * `POST /v1/orgs/{org}/record/commits` under the session's lineage key: the
17
+ * gateway's `x-mnemom-lineage-key`, captured by the local proxy into the
18
+ * record-state file (the CLI never derives one). Every session commit is
19
+ * reported, trailer or not: a commit is bound when the index holds its sha or
20
+ * patch-id, never by its trailer alone.
21
+ *
22
+ * PRERELEASE DEFAULT. On for a prerelease build (`next`), off for stable;
23
+ * MNEMOM_AGENT_COMMIT_REPORT=on|off overrides, and any other value reads off.
24
+ *
25
+ * FAIL OPEN. Nothing here throws into the launch or changes its exit code. A
26
+ * dark route (404) stops reporting for the session; a failed POST is retried on
27
+ * the next poll; a commit whose fields cannot be read is not sent (it stays
28
+ * unbound rather than a doomed request). The final sweep at exit races a hard
29
+ * deadline.
30
+ */
31
+ export declare const COMMIT_REPORT_ENV = "MNEMOM_AGENT_COMMIT_REPORT";
32
+ export declare const TRAILER_KEY = "Mnemom-Conversation";
33
+ /** Claude Code's own commit attribution when none is configured (settings reference, `attribution`). */
34
+ export declare const CC_DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: Claude <noreply@anthropic.com>";
35
+ /** How often the launcher looks for new commits while the session runs. */
36
+ export declare const COMMIT_POLL_MS = 5000;
37
+ /** The final sweep at exit shares W1.3's 2 s exit budget with the conduct receipt. */
38
+ export declare const COMMIT_FINAL_DEADLINE_MS = 1500;
39
+ /** on | off | unset (prerelease default); any other value reads off. */
40
+ export declare function commitReportEnabled(raw: string | undefined, version: string): boolean;
41
+ /**
42
+ * The `--settings` fragment carrying the trailer. `user` is the commit attribution
43
+ * the user's settings files give (highest precedence first; `false` hides all
44
+ * attribution, an empty string hides the commit line): it is kept in front of ours.
45
+ */
46
+ export declare function attributionSettings(conversationId: string, user: string | false | undefined): {
47
+ attribution: {
48
+ commit: string;
49
+ };
50
+ };
51
+ /** `owner/name` from a GitHub remote URL (https, ssh or scp form), or null. */
52
+ export declare function parseRepoSlug(url: string): string | null;
53
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
54
+ export declare const COMMIT_SCHEMA = "RecordCommit/v1";
55
+ /** RecordCommit/v1: strings only, every field required; the route rejects an unknown field. */
56
+ export interface RecordCommitBody {
57
+ schema: typeof COMMIT_SCHEMA;
58
+ lineage_key: string;
59
+ repo: string;
60
+ branch: string;
61
+ sha: string;
62
+ patch_id: string;
63
+ }
64
+ /** Runs git in the session's repository; null on any failure. */
65
+ export type Git = (args: string[], input?: string) => string | null;
66
+ export declare function realGit(cwd: string): Git;
67
+ /**
68
+ * Commits made since `sinceSec` (committer time) on local branches by this
69
+ * repository's git user, oldest first. Remote-tracking refs are left out, so a
70
+ * teammate's fetched commit is never reported as this session's.
71
+ */
72
+ export declare function sessionCommits(git: Git, sinceSec: number): string[];
73
+ /** The body for one commit, or the fields that could not be read. */
74
+ export declare function commitBody(git: Git, sha: string, lineageKey: string): {
75
+ ok: true;
76
+ body: RecordCommitBody;
77
+ } | {
78
+ ok: false;
79
+ missing: string[];
80
+ };
81
+ export type PostResult = "ok" | "dark" | "retry" | "drop";
82
+ export interface ReporterOptions {
83
+ orgId: string;
84
+ /** The proxy's record-state file, carrying the gateway's lineage key. */
85
+ recordStateFile: string;
86
+ /** Launch time, seconds since the epoch: commits before it are not the session's. */
87
+ sinceSec: number;
88
+ git: Git;
89
+ intervalMs?: number;
90
+ /** Test seam; the real one is postRecordCommit. */
91
+ post?: (orgId: string, body: RecordCommitBody, signal: AbortSignal) => Promise<PostResult>;
92
+ now?: () => number;
93
+ }
94
+ /** postRecordCommit as a PostResult: 404 dark, 429/5xx/network retry, any other 4xx drop. */
95
+ export declare function postCommit(orgId: string, body: RecordCommitBody, signal: AbortSignal): Promise<PostResult>;
96
+ /** The reporter's state across polls (exported for tests). */
97
+ export interface ReporterState {
98
+ done: Set<string>;
99
+ dark: boolean;
100
+ }
101
+ /** One sweep: report every session commit not reported yet. Never throws. */
102
+ export declare function reportOnce(o: ReporterOptions, state: ReporterState, signal: AbortSignal): Promise<void>;
103
+ /**
104
+ * Poll while the session runs; the returned `finish` runs one last sweep under
105
+ * a hard deadline and stops the timer. The timer is unref'd.
106
+ */
107
+ export declare function startCommitReporter(o: ReporterOptions): {
108
+ finish: (deadlineMs?: number) => Promise<void>;
109
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Door one's commit reporting (Doors W1.8, OD-3, OD-5): the launcher adds the
3
+ * `Mnemom-Conversation` trailer and reports every commit the session makes to
4
+ * Record's commit index, so the PR record reads "N of M commits bound" with N
5
+ * equal to M.
6
+ *
7
+ * TRAILER. Claude Code writes `attribution.commit` into each commit it makes; the
8
+ * launcher passes it through `--settings` (OD-3). The model writes it, so it is
9
+ * best-effort; the commit index is the proof. The user's own commit attribution
10
+ * (or Claude Code's default Co-Authored-By line) is kept in front of ours, because
11
+ * a `--settings` value replaces theirs.
12
+ *
13
+ * INDEX. While Claude Code runs, the launcher looks for commits made in this
14
+ * repository since the launch, on local branches, by this repository's git user,
15
+ * and POSTs each one (sha, `git patch-id --stable`, branch, repo) to
16
+ * `POST /v1/orgs/{org}/record/commits` under the session's lineage key: the
17
+ * gateway's `x-mnemom-lineage-key`, captured by the local proxy into the
18
+ * record-state file (the CLI never derives one). Every session commit is
19
+ * reported, trailer or not: a commit is bound when the index holds its sha or
20
+ * patch-id, never by its trailer alone.
21
+ *
22
+ * PRERELEASE DEFAULT. On for a prerelease build (`next`), off for stable;
23
+ * MNEMOM_AGENT_COMMIT_REPORT=on|off overrides, and any other value reads off.
24
+ *
25
+ * FAIL OPEN. Nothing here throws into the launch or changes its exit code. A
26
+ * dark route (404) stops reporting for the session; a failed POST is retried on
27
+ * the next poll; a commit whose fields cannot be read is not sent (it stays
28
+ * unbound rather than a doomed request). The final sweep at exit races a hard
29
+ * deadline.
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { postRecordCommit } from "./api.js";
33
+ import { readLineage } from "./statusline-feed.js";
34
+ import { isPrerelease } from "./update/semver.js";
35
+ export const COMMIT_REPORT_ENV = "MNEMOM_AGENT_COMMIT_REPORT";
36
+ export const TRAILER_KEY = "Mnemom-Conversation";
37
+ /** Claude Code's own commit attribution when none is configured (settings reference, `attribution`). */
38
+ export const CC_DEFAULT_COMMIT_ATTRIBUTION = "Co-Authored-By: Claude <noreply@anthropic.com>";
39
+ /** How often the launcher looks for new commits while the session runs. */
40
+ export const COMMIT_POLL_MS = 5_000;
41
+ /** The final sweep at exit shares W1.3's 2 s exit budget with the conduct receipt. */
42
+ export const COMMIT_FINAL_DEADLINE_MS = 1_500;
43
+ const HEX40 = /^[0-9a-f]{40}$/;
44
+ const REPO_SLUG = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
45
+ const BRANCH_MAX = 255;
46
+ /** on | off | unset (prerelease default); any other value reads off. */
47
+ export function commitReportEnabled(raw, version) {
48
+ const v = (raw ?? "").trim().toLowerCase();
49
+ if (v === "")
50
+ return isPrerelease(version);
51
+ return ["on", "1", "true", "yes"].includes(v);
52
+ }
53
+ /**
54
+ * The `--settings` fragment carrying the trailer. `user` is the commit attribution
55
+ * the user's settings files give (highest precedence first; `false` hides all
56
+ * attribution, an empty string hides the commit line): it is kept in front of ours.
57
+ */
58
+ export function attributionSettings(conversationId, user) {
59
+ const ours = `${TRAILER_KEY}: ${conversationId}`;
60
+ const base = user === undefined ? CC_DEFAULT_COMMIT_ATTRIBUTION : user === false ? "" : user;
61
+ return { attribution: { commit: base.trim() ? `${base.trimEnd()}\n${ours}` : ours } };
62
+ }
63
+ /** `owner/name` from a GitHub remote URL (https, ssh or scp form), or null. */
64
+ export function parseRepoSlug(url) {
65
+ const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
66
+ if (!m)
67
+ return null;
68
+ const slug = `${m[1]}/${m[2]}`;
69
+ return REPO_SLUG.test(slug) ? slug : null;
70
+ }
71
+ /** The commit index body's schema tag (shared with the fleet worker's reporter). */
72
+ export const COMMIT_SCHEMA = "RecordCommit/v1";
73
+ export function realGit(cwd) {
74
+ return (args, input) => {
75
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", input, timeout: 10_000 });
76
+ return r.status === 0 ? r.stdout : null;
77
+ };
78
+ }
79
+ /**
80
+ * Commits made since `sinceSec` (committer time) on local branches by this
81
+ * repository's git user, oldest first. Remote-tracking refs are left out, so a
82
+ * teammate's fetched commit is never reported as this session's.
83
+ */
84
+ export function sessionCommits(git, sinceSec) {
85
+ const email = (git(["config", "user.email"]) ?? "").trim();
86
+ if (!email)
87
+ return [];
88
+ const out = git(["log", "--branches", "--reverse", `--since=@${sinceSec}`, "--format=%H%x09%ce"]);
89
+ if (out === null)
90
+ return [];
91
+ return out
92
+ .split("\n")
93
+ .map((l) => l.split("\t"))
94
+ .filter(([sha, ce]) => sha !== undefined && HEX40.test(sha) && ce === email)
95
+ .map(([sha]) => sha);
96
+ }
97
+ /** The body for one commit, or the fields that could not be read. */
98
+ export function commitBody(git, sha, lineageKey) {
99
+ const diff = git(["diff-tree", "-p", "--root", sha]);
100
+ const patchId = diff
101
+ ? (/^\s*([0-9a-f]{40})\b/.exec(git(["patch-id", "--stable"], diff) ?? "")?.[1] ?? "")
102
+ : "";
103
+ const current = (git(["symbolic-ref", "--quiet", "--short", "HEAD"]) ?? "").trim();
104
+ const holders = (git(["for-each-ref", "--contains", sha, "--format=%(refname:short)", "refs/heads"]) ?? "")
105
+ .split("\n")
106
+ .map((s) => s.trim())
107
+ .filter(Boolean);
108
+ const branch = holders.includes(current) ? current : (holders[0] ?? "");
109
+ const repo = parseRepoSlug(git(["remote", "get-url", "origin"]) ?? "") ?? "";
110
+ const missing = [];
111
+ if (!HEX40.test(sha))
112
+ missing.push("sha");
113
+ // A merge commit's or an empty commit's diff has no patch-id.
114
+ if (!HEX40.test(patchId))
115
+ missing.push("patch_id");
116
+ if (!branch || branch.length > BRANCH_MAX)
117
+ missing.push("branch");
118
+ if (!repo)
119
+ missing.push("repo");
120
+ if (missing.length > 0)
121
+ return { ok: false, missing };
122
+ return {
123
+ ok: true,
124
+ body: { schema: COMMIT_SCHEMA, lineage_key: lineageKey, repo, branch, sha, patch_id: patchId },
125
+ };
126
+ }
127
+ /** postRecordCommit as a PostResult: 404 dark, 429/5xx/network retry, any other 4xx drop. */
128
+ export async function postCommit(orgId, body, signal) {
129
+ try {
130
+ const status = await postRecordCommit(orgId, body, signal);
131
+ if (status >= 200 && status < 300)
132
+ return "ok";
133
+ if (status === 404)
134
+ return "dark";
135
+ if (status === 429 || status >= 500)
136
+ return "retry";
137
+ return "drop";
138
+ }
139
+ catch {
140
+ return "retry";
141
+ }
142
+ }
143
+ /** One sweep: report every session commit not reported yet. Never throws. */
144
+ export async function reportOnce(o, state, signal) {
145
+ if (state.dark)
146
+ return;
147
+ const lineageKey = readLineage(o.recordStateFile);
148
+ if (!lineageKey)
149
+ return;
150
+ for (const sha of sessionCommits(o.git, o.sinceSec)) {
151
+ if (state.done.has(sha) || signal.aborted)
152
+ continue;
153
+ const built = commitBody(o.git, sha, lineageKey);
154
+ if (!built.ok) {
155
+ state.done.add(sha);
156
+ continue;
157
+ }
158
+ const r = await (o.post ?? postCommit)(o.orgId, built.body, signal);
159
+ if (r === "dark") {
160
+ state.dark = true;
161
+ return;
162
+ }
163
+ if (r !== "retry")
164
+ state.done.add(sha);
165
+ }
166
+ }
167
+ /**
168
+ * Poll while the session runs; the returned `finish` runs one last sweep under
169
+ * a hard deadline and stops the timer. The timer is unref'd.
170
+ */
171
+ export function startCommitReporter(o) {
172
+ const state = { done: new Set(), dark: false };
173
+ let running = null;
174
+ const sweep = (signal) => {
175
+ running ??= reportOnce(o, state, signal)
176
+ .catch(() => undefined)
177
+ .finally(() => {
178
+ running = null;
179
+ });
180
+ return running;
181
+ };
182
+ const timer = setInterval(() => void sweep(AbortSignal.timeout(o.intervalMs ?? COMMIT_POLL_MS)), o.intervalMs ?? COMMIT_POLL_MS);
183
+ timer.unref();
184
+ return {
185
+ async finish(deadlineMs = COMMIT_FINAL_DEADLINE_MS) {
186
+ clearInterval(timer);
187
+ const signal = AbortSignal.timeout(deadlineMs);
188
+ let timeout;
189
+ const deadline = new Promise((r) => {
190
+ timeout = setTimeout(r, deadlineMs);
191
+ timeout.unref();
192
+ });
193
+ // Let an in-flight poll finish first, then sweep once more; both inside the deadline.
194
+ await Promise.race([
195
+ (async () => {
196
+ if (running)
197
+ await running;
198
+ await sweep(signal);
199
+ })(),
200
+ deadline,
201
+ ]);
202
+ clearTimeout(timeout);
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The org Record keys a launch's lineage under (Doors W1.2, W1.4, W1.8).
3
+ *
4
+ * The gateway writes a request's record deltas under the org it bills, which is the served
5
+ * agent's org. The launcher's account org can differ: with an org API key, the account
6
+ * preflight resolves the key owner's personal org (`/v1/auth/me/personal-org`, a `pers-*`
7
+ * id), so a totals read or commit report keyed on it lands on an org that holds no row for
8
+ * the lineage, and Record answers 404. Every Record read and write of a launch (conduct
9
+ * receipt, status-line feed, commit reporting) therefore uses the served agent's org.
10
+ *
11
+ * FAIL-OPEN, BOUNDED. One agent read, raced against RECORD_ORG_TIMEOUT_MS; a failed, slow or
12
+ * off-shape answer falls back to the account org (the behaviour before this read existed), and
13
+ * `onFallback` says why: Record answers a wrong-org read or commit report with 404, which its
14
+ * callers treat as "dark", so a silent fallback would look like Record being off. Never throws.
15
+ */
16
+ /** The most the read adds to launch. */
17
+ export declare const RECORD_ORG_TIMEOUT_MS = 1500;
18
+ export interface RecordOrgDeps {
19
+ /** The served agent's record; only `org_id` is read. */
20
+ readAgent?: (agentId: string) => Promise<{
21
+ org_id?: string | null;
22
+ }>;
23
+ timeoutMs?: number;
24
+ /** Told why the account org was used instead (one line, no secret). */
25
+ onFallback?: (reason: string) => void;
26
+ }
27
+ export declare function recordOrgFor(agentId: string, accountOrgId: string, deps?: RecordOrgDeps): Promise<string>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The org Record keys a launch's lineage under (Doors W1.2, W1.4, W1.8).
3
+ *
4
+ * The gateway writes a request's record deltas under the org it bills, which is the served
5
+ * agent's org. The launcher's account org can differ: with an org API key, the account
6
+ * preflight resolves the key owner's personal org (`/v1/auth/me/personal-org`, a `pers-*`
7
+ * id), so a totals read or commit report keyed on it lands on an org that holds no row for
8
+ * the lineage, and Record answers 404. Every Record read and write of a launch (conduct
9
+ * receipt, status-line feed, commit reporting) therefore uses the served agent's org.
10
+ *
11
+ * FAIL-OPEN, BOUNDED. One agent read, raced against RECORD_ORG_TIMEOUT_MS; a failed, slow or
12
+ * off-shape answer falls back to the account org (the behaviour before this read existed), and
13
+ * `onFallback` says why: Record answers a wrong-org read or commit report with 404, which its
14
+ * callers treat as "dark", so a silent fallback would look like Record being off. Never throws.
15
+ */
16
+ import { getAgent } from "./api.js";
17
+ /** The most the read adds to launch. */
18
+ export const RECORD_ORG_TIMEOUT_MS = 1500;
19
+ const ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
20
+ export async function recordOrgFor(agentId, accountOrgId, deps = {}) {
21
+ const readAgent = deps.readAgent ?? getAgent;
22
+ let timer;
23
+ const timeout = new Promise((resolve) => {
24
+ timer = setTimeout(() => resolve(null), deps.timeoutMs ?? RECORD_ORG_TIMEOUT_MS);
25
+ });
26
+ try {
27
+ let failed = false;
28
+ const agent = await Promise.race([
29
+ readAgent(agentId).catch(() => {
30
+ failed = true;
31
+ return null;
32
+ }),
33
+ timeout,
34
+ ]);
35
+ const org = agent?.org_id;
36
+ if (typeof org === "string" && ORG_ID_RE.test(org))
37
+ return org;
38
+ deps.onFallback?.(failed ? "agent read failed" : agent ? "agent has no org" : "agent read timed out");
39
+ return accountOrgId;
40
+ }
41
+ finally {
42
+ clearTimeout(timer);
43
+ }
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.32-next.0",
3
+ "version": "0.17.32-next.2",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {