@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.
- package/dist/commands/agent-share-fixture.d.ts +20 -0
- package/dist/commands/agent-share-fixture.js +134 -0
- package/dist/commands/agent-share.d.ts +87 -0
- package/dist/commands/agent-share.js +251 -0
- package/dist/commands/agent.d.ts +32 -1
- package/dist/commands/agent.js +79 -9
- package/dist/index.js +17 -0
- package/dist/lib/agent.d.ts +2 -0
- package/dist/lib/agent.js +7 -0
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +19 -0
- package/dist/lib/commit-report.d.ts +109 -0
- package/dist/lib/commit-report.js +205 -0
- package/dist/lib/record-org.d.ts +27 -0
- package/dist/lib/record-org.js +44 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/agent.js
CHANGED
|
@@ -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:
|
|
749
|
+
? { orgId: recordOrgId, agentId: servedAgentId }
|
|
738
750
|
: undefined,
|
|
739
|
-
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
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
}, {
|
|
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")
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -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
|
+
}
|