@mnemom/mnemom 0.17.31 → 0.17.32-next.1
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/base-url-proxy.mjs +30 -0
- package/dist/commands/agent-fuel.d.ts +75 -0
- package/dist/commands/agent-fuel.js +298 -0
- package/dist/commands/agent-implicit-notice.d.ts +31 -0
- package/dist/commands/agent-implicit-notice.js +73 -0
- package/dist/commands/agent-session.d.ts +2 -1
- package/dist/commands/agent-session.js +2 -1
- 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 +75 -2
- package/dist/commands/agent.js +165 -28
- package/dist/commands/status.js +1 -1
- package/dist/commands/update.js +3 -1
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +1 -1
- package/dist/index.js +68 -12
- package/dist/lib/agent.d.ts +34 -19
- package/dist/lib/agent.js +41 -32
- package/dist/lib/api.d.ts +66 -0
- package/dist/lib/api.js +50 -10
- package/dist/lib/canonical-json.d.ts +12 -0
- package/dist/lib/canonical-json.js +22 -0
- package/dist/lib/commit-report.d.ts +109 -0
- package/dist/lib/commit-report.js +205 -0
- package/dist/lib/conduct-receipt.d.ts +81 -0
- package/dist/lib/conduct-receipt.js +177 -0
- package/dist/lib/contract-hash.d.ts +61 -0
- package/dist/lib/contract-hash.js +126 -0
- package/dist/lib/experimental.d.ts +10 -1
- package/dist/lib/experimental.js +35 -1
- package/dist/lib/oauth.d.ts +2 -1
- package/dist/lib/oauth.js +2 -1
- package/dist/lib/statusline-bin.d.ts +2 -2
- package/dist/lib/statusline-bin.js +4 -7
- package/dist/lib/statusline-feed.d.ts +33 -0
- package/dist/lib/statusline-feed.js +68 -0
- package/dist/lib/statusline.d.ts +39 -88
- package/dist/lib/statusline.js +58 -250
- package/dist/lib/update/decide.d.ts +22 -3
- package/dist/lib/update/decide.js +35 -12
- package/dist/lib/update/doctor.js +5 -1
- package/dist/lib/update/run.d.ts +2 -0
- package/dist/lib/update/run.js +22 -8
- package/package.json +1 -1
package/dist/lib/agent.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { createHash, randomUUID } from "node:crypto";
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
24
|
import { join } from "node:path";
|
|
25
|
+
import { canonicalJson } from "./canonical-json.js";
|
|
25
26
|
import { getGatewayUrl } from "./config.js";
|
|
26
27
|
import { normSession } from "./slack-route.js";
|
|
27
28
|
/** The us-2/prod gateway host. The default door — never any other cell silently. */
|
|
@@ -313,6 +314,13 @@ export function readCcSettings(opts) {
|
|
|
313
314
|
const view = {};
|
|
314
315
|
if (typeof o.model === "string")
|
|
315
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
|
+
}
|
|
316
324
|
if (o.env && typeof o.env === "object") {
|
|
317
325
|
view.env = Object.fromEntries(Object.entries(o.env).filter((e) => typeof e[1] === "string"));
|
|
318
326
|
}
|
|
@@ -438,6 +446,7 @@ export const AGENT_SUB_VERBS = new Set([
|
|
|
438
446
|
"move",
|
|
439
447
|
"export",
|
|
440
448
|
"sessions",
|
|
449
|
+
"fuel",
|
|
441
450
|
"show",
|
|
442
451
|
"goal",
|
|
443
452
|
]);
|
|
@@ -480,23 +489,7 @@ export function isPositiveNumber(value) {
|
|
|
480
489
|
const n = Number(s);
|
|
481
490
|
return Number.isFinite(n) && n > 0;
|
|
482
491
|
}
|
|
483
|
-
|
|
484
|
-
* Canonical JSON — object keys sorted recursively, arrays in order, no
|
|
485
|
-
* whitespace, `undefined` keys dropped. Byte-identical to the gateway's
|
|
486
|
-
* `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
|
|
487
|
-
* sha we print match what the gateway seals.
|
|
488
|
-
*/
|
|
489
|
-
export function canonicalJson(value) {
|
|
490
|
-
if (value === null || typeof value !== "object")
|
|
491
|
-
return JSON.stringify(value);
|
|
492
|
-
if (Array.isArray(value))
|
|
493
|
-
return `[${value.map(canonicalJson).join(",")}]`;
|
|
494
|
-
const obj = value;
|
|
495
|
-
const keys = Object.keys(obj)
|
|
496
|
-
.filter((k) => obj[k] !== undefined)
|
|
497
|
-
.sort();
|
|
498
|
-
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
499
|
-
}
|
|
492
|
+
export { canonicalJson } from "./canonical-json.js";
|
|
500
493
|
/** sha256 hex (first 16 chars) of a string — the operator-eyeball contract hash. */
|
|
501
494
|
export function sha256Hex16(text) {
|
|
502
495
|
return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
|
|
@@ -652,7 +645,8 @@ export const COMPACTION_INTERCEPT_KEY = "GATEWAY_COMPACTION_INTERCEPT";
|
|
|
652
645
|
/**
|
|
653
646
|
* The gateway env key that has it measure each request's saving for a single session and put
|
|
654
647
|
* it in the client-facing usage (`mnemom_savings` in the read `usage.iterations` entry), where
|
|
655
|
-
*
|
|
648
|
+
* it lands in Claude Code's transcript. The status bar never shows it (decision 14: never
|
|
649
|
+
* "saved"). Default OFF on the gateway; sent `"on"`
|
|
656
650
|
* only while the `measured-savings` experimental feature is on and the launched CLI is Claude
|
|
657
651
|
* Code (`resolveUsageSavings`). Independent of folding, so every profile sends it — `off` too,
|
|
658
652
|
* where the measured saving is honestly $0.00.
|
|
@@ -1283,9 +1277,9 @@ export function resolveCompactionIntercept(opts) {
|
|
|
1283
1277
|
return !(raw === "0" || raw === "false" || raw === "off" || raw === "no");
|
|
1284
1278
|
}
|
|
1285
1279
|
/**
|
|
1286
|
-
* Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1280
|
+
* Does the gateway measure this launch's saving (`GATEWAY_USAGE_SAVINGS` in the header)? Only
|
|
1281
|
+
* with the `measured-savings` experimental feature on, and only for Claude Code, whose
|
|
1282
|
+
* transcript keeps the measurement. No flag or
|
|
1289
1283
|
* launcher.toml key: `mnemom experimental enable measured-savings` is the switch.
|
|
1290
1284
|
*/
|
|
1291
1285
|
export function resolveUsageSavings(opts) {
|
|
@@ -1333,27 +1327,42 @@ export function buildHandoffMcpConfig(p) {
|
|
|
1333
1327
|
export function buildHandoffMcpConfigJson(p) {
|
|
1334
1328
|
return JSON.stringify(buildHandoffMcpConfig(p));
|
|
1335
1329
|
}
|
|
1330
|
+
/** Does a Claude Code passthrough run headless (`-p` / `--print`)? */
|
|
1331
|
+
export function passthroughIsPrint(passthrough) {
|
|
1332
|
+
return passthrough.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
|
|
1333
|
+
}
|
|
1336
1334
|
/**
|
|
1337
1335
|
* Is this launch implicit-mode? Precedence, highest wins:
|
|
1338
1336
|
* 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
|
|
1339
1337
|
* 2. a `--goal` statement — a sealed contract is a per-launch choice that the
|
|
1340
|
-
* standing config default must never silently override
|
|
1338
|
+
* standing config default must never silently override, so a launch never
|
|
1339
|
+
* runs both modes;
|
|
1341
1340
|
* 3. the config default `goal_mode` — `"off"` turns alignment off, `"implicit"`
|
|
1342
1341
|
* keeps it implicit;
|
|
1343
|
-
* 4. with the `implicit` experimental feature ON, IMPLICIT
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1342
|
+
* 4. with the `implicit` experimental feature ON, IMPLICIT (as before T16);
|
|
1343
|
+
* 5. on a prerelease (`next`) build, IMPLICIT for an interactive or Remote Control
|
|
1344
|
+
* launch and OFF for a headless `-p` one (OD-13: T16 never covers `-p`);
|
|
1345
|
+
* 6. otherwise OFF — stable without the experimental feature behaves as today.
|
|
1347
1346
|
* Pure so every arm is unit-tested; the caller passes the resolved value on to
|
|
1348
1347
|
* `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
|
|
1349
1348
|
* combined with a contract flag — that conflict is EXPLICIT and is reported).
|
|
1350
1349
|
*/
|
|
1351
|
-
export function
|
|
1350
|
+
export function resolveImplicitSource(opts) {
|
|
1352
1351
|
if (opts.implicit !== undefined)
|
|
1353
|
-
return opts.implicit;
|
|
1352
|
+
return { implicit: opts.implicit, source: "flag" };
|
|
1354
1353
|
if (hasText(opts.goal))
|
|
1355
|
-
return false;
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1354
|
+
return { implicit: false, source: "goal" };
|
|
1355
|
+
const experimentalOn = opts.experimentalOn !== false;
|
|
1356
|
+
if (!experimentalOn && !opts.prerelease)
|
|
1357
|
+
return { implicit: false, source: "default" };
|
|
1358
|
+
if (opts.configGoalMode !== undefined) {
|
|
1359
|
+
return { implicit: opts.configGoalMode === "implicit", source: "config" };
|
|
1360
|
+
}
|
|
1361
|
+
if (experimentalOn)
|
|
1362
|
+
return { implicit: true, source: "experimental" };
|
|
1363
|
+
return { implicit: !opts.print, source: opts.print ? "default" : "prerelease" };
|
|
1364
|
+
}
|
|
1365
|
+
/** `resolveImplicitSource(opts).implicit`. */
|
|
1366
|
+
export function resolveImplicit(opts) {
|
|
1367
|
+
return resolveImplicitSource(opts).implicit;
|
|
1359
1368
|
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -236,6 +236,12 @@ export declare function verifyBinding(agentId: string, keyHash: string): Promise
|
|
|
236
236
|
bound: boolean;
|
|
237
237
|
key_prefix: string | null;
|
|
238
238
|
}>;
|
|
239
|
+
/**
|
|
240
|
+
* GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
|
|
241
|
+
* the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
|
|
242
|
+
* agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
|
|
243
|
+
* view, so an unauthenticated read here refuses every pin.
|
|
244
|
+
*/
|
|
239
245
|
export declare function getAgent(id: string): Promise<Agent>;
|
|
240
246
|
export interface AgentListItem {
|
|
241
247
|
id: string;
|
|
@@ -431,6 +437,32 @@ export interface MuBalance {
|
|
|
431
437
|
effectiveMode?: string;
|
|
432
438
|
enforcementPausedReason?: string | null;
|
|
433
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* GET /v1/orgs/{org_id}/record/totals?lineage_key= — one lineage's model cost and μ as
|
|
442
|
+
* decimal-string millimu (`mu_charged_mmu` null until billing commits a charge). Dark unless the
|
|
443
|
+
* org is in the record-accumulator cohort: a 404 then, like an unknown lineage.
|
|
444
|
+
*/
|
|
445
|
+
export interface RecordTotals {
|
|
446
|
+
lineage_key: string;
|
|
447
|
+
model_cost_mmu: string;
|
|
448
|
+
mu_charged_mmu: string | null;
|
|
449
|
+
as_of: string;
|
|
450
|
+
row_count: number;
|
|
451
|
+
}
|
|
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>;
|
|
434
466
|
export declare function getMuBalance(orgId?: string): Promise<MuBalance>;
|
|
435
467
|
/**
|
|
436
468
|
* Mint the one-time `cards:write:protection` capability for an agent — the
|
|
@@ -1277,6 +1309,40 @@ export interface OrgUsageQuery {
|
|
|
1277
1309
|
* the user "org not found" on what is most often just the flag being off.
|
|
1278
1310
|
*/
|
|
1279
1311
|
export declare function getOrgUsage(orgId: string, opts?: OrgUsageQuery): Promise<OrgUsageResponse>;
|
|
1312
|
+
/** One sealed contract in the batch-seal response (mnemom-api migration 582). */
|
|
1313
|
+
export interface SealedContractRow {
|
|
1314
|
+
seal_id: string;
|
|
1315
|
+
ordinal: number;
|
|
1316
|
+
contract_sha256: string;
|
|
1317
|
+
/** The deterministic node identity: `contract:<org_id>:<sha256>`. */
|
|
1318
|
+
node_id: string;
|
|
1319
|
+
version: number;
|
|
1320
|
+
job_key: string | null;
|
|
1321
|
+
canon_status: "pending" | "written";
|
|
1322
|
+
}
|
|
1323
|
+
/** `POST /v1/orgs/{org_id}/contract-step/batch-seal` 200 body. */
|
|
1324
|
+
export interface BatchSealResponse {
|
|
1325
|
+
batch_id: string;
|
|
1326
|
+
replayed: boolean;
|
|
1327
|
+
repo: string;
|
|
1328
|
+
seals: SealedContractRow[];
|
|
1329
|
+
/** Whether the sealed contracts were handed to dispatch: off, enqueued, or deferred (fail open). */
|
|
1330
|
+
dispatch: "off" | "enqueued" | "deferred";
|
|
1331
|
+
}
|
|
1332
|
+
export interface BatchSealRequest {
|
|
1333
|
+
repo: string;
|
|
1334
|
+
source: "cli";
|
|
1335
|
+
contracts: Array<{
|
|
1336
|
+
contract: unknown;
|
|
1337
|
+
job_key?: string;
|
|
1338
|
+
}>;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
|
|
1342
|
+
* Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
|
|
1343
|
+
* instead of sealing twice.
|
|
1344
|
+
*/
|
|
1345
|
+
export declare function sealContractBatch(orgId: string, body: BatchSealRequest, idempotencyKey: string, githubToken: string): Promise<BatchSealResponse>;
|
|
1280
1346
|
/** `GET /v1/code/entitlement` 200 body — why Mnemom Agent is enabled for the org. */
|
|
1281
1347
|
export interface CodeEntitlement {
|
|
1282
1348
|
org_id: string;
|
package/dist/lib/api.js
CHANGED
|
@@ -112,14 +112,6 @@ export async function readApiError(response, fallback) {
|
|
|
112
112
|
specDeviation: parsed.spec_deviation,
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
|
-
async function fetchApi(endpoint) {
|
|
116
|
-
const url = validateUrl(`${API_BASE}${endpoint}`);
|
|
117
|
-
const response = await mnemomFetch(url);
|
|
118
|
-
if (!response.ok) {
|
|
119
|
-
throw await readApiError(response, "API request failed");
|
|
120
|
-
}
|
|
121
|
-
return response.json();
|
|
122
|
-
}
|
|
123
115
|
export async function postApi(endpoint, body, opts = {}) {
|
|
124
116
|
const url = validateUrl(`${API_BASE}${endpoint}`);
|
|
125
117
|
const headers = {
|
|
@@ -197,8 +189,19 @@ async function fetchWithAuthRetry(url, buildInit) {
|
|
|
197
189
|
return first;
|
|
198
190
|
return mnemomFetch(url, await buildInit());
|
|
199
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* GET /v1/agents/:id, sent with the caller's credentials. The API answers with the projection
|
|
194
|
+
* the caller may see: the org-member view (claimed_by, org_id, `caller: "org_member"`) for an
|
|
195
|
+
* agent in one of the caller's orgs, the public view otherwise. A pinned launch needs the member
|
|
196
|
+
* view, so an unauthenticated read here refuses every pin.
|
|
197
|
+
*/
|
|
200
198
|
export async function getAgent(id) {
|
|
201
|
-
|
|
199
|
+
const url = validateUrl(`${API_BASE}/v1/agents/${id}`);
|
|
200
|
+
const response = await fetchWithAuthRetry(url, async () => ({ headers: await authHeaders() }));
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
throw await readApiError(response, "API request failed");
|
|
203
|
+
}
|
|
204
|
+
return (await response.json());
|
|
202
205
|
}
|
|
203
206
|
/**
|
|
204
207
|
* Key-compatible agent listing — `GET /v1/agents` is scoped by the caller's
|
|
@@ -405,6 +408,28 @@ export async function getMyPersonalOrg() {
|
|
|
405
408
|
export async function getMe() {
|
|
406
409
|
return gFetch("/v1/auth/me", {}, { fallback: "Could not read your identity" });
|
|
407
410
|
}
|
|
411
|
+
export async function getRecordTotals(orgId, lineageKey, signal) {
|
|
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
|
+
}
|
|
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
|
+
}
|
|
408
433
|
export async function getMuBalance(orgId) {
|
|
409
434
|
const q = orgId ? `?org_id=${encodeURIComponent(orgId)}` : "";
|
|
410
435
|
return gFetch(`/v1/billing/mu${q}`, {}, { fallback: "Could not read MU balance" });
|
|
@@ -747,7 +772,7 @@ function emptyIntegrityRow(agentId) {
|
|
|
747
772
|
export async function getTraces(id, limit = 10) {
|
|
748
773
|
const data = await gFetch(`/v1/traces?agent_id=${id}&limit=${limit}`, {}, { fallback: "API request failed" });
|
|
749
774
|
// Accept both the envelope shape (current API) and a bare array (defensive
|
|
750
|
-
// —
|
|
775
|
+
// — the test cell is mid-deploy when a CLI smoke runs against an older API).
|
|
751
776
|
if (Array.isArray(data))
|
|
752
777
|
return data;
|
|
753
778
|
return data.traces ?? [];
|
|
@@ -1308,6 +1333,7 @@ async function gFetch(path, init = {}, opts = {}) {
|
|
|
1308
1333
|
...(init.headers ?? {}),
|
|
1309
1334
|
},
|
|
1310
1335
|
body: init.body,
|
|
1336
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
1311
1337
|
}));
|
|
1312
1338
|
if (!response.ok) {
|
|
1313
1339
|
if (response.status === 401)
|
|
@@ -1569,6 +1595,20 @@ async function codeFetch(path, init = {}, fallback) {
|
|
|
1569
1595
|
}
|
|
1570
1596
|
return (await response.json());
|
|
1571
1597
|
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Seal a batch of job contracts in one transaction (Doors fuel step, W3.2). The caller passes the
|
|
1600
|
+
* Idempotency-Key and holds it across retries, so a retried confirmation replays the first batch
|
|
1601
|
+
* instead of sealing twice.
|
|
1602
|
+
*/
|
|
1603
|
+
export async function sealContractBatch(orgId, body, idempotencyKey, githubToken) {
|
|
1604
|
+
return codeFetch(`/v1/orgs/${encodeURIComponent(orgId)}/contract-step/batch-seal`, {
|
|
1605
|
+
method: "POST",
|
|
1606
|
+
body: JSON.stringify(body),
|
|
1607
|
+
// The api checks with this token that the caller can write to the repo (OD-10). It reads
|
|
1608
|
+
// it for that check only and never stores it.
|
|
1609
|
+
headers: { "Idempotency-Key": idempotencyKey, "X-GitHub-Token": githubToken },
|
|
1610
|
+
}, "Could not seal the contracts");
|
|
1611
|
+
}
|
|
1572
1612
|
/** The machine-readable code on the gate's 403 when the org is not enabled. */
|
|
1573
1613
|
export const CODE_NOT_ENABLED = "code_not_enabled";
|
|
1574
1614
|
/**
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
|
|
3
|
+
* with no imports, so the contract hash helper and the thin runner can use it without loading
|
|
4
|
+
* agent.ts. `agent.ts` re-exports it for its existing importers.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Canonical JSON — object keys sorted recursively, arrays in order, no
|
|
8
|
+
* whitespace, `undefined` keys dropped. Byte-identical to the gateway's
|
|
9
|
+
* `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
|
|
10
|
+
* sha we print match what the gateway seals.
|
|
11
|
+
*/
|
|
12
|
+
export declare function canonicalJson(value: unknown): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON: the one place the CLI serialises a contract for hashing. Kept in its own module,
|
|
3
|
+
* with no imports, so the contract hash helper and the thin runner can use it without loading
|
|
4
|
+
* agent.ts. `agent.ts` re-exports it for its existing importers.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Canonical JSON — object keys sorted recursively, arrays in order, no
|
|
8
|
+
* whitespace, `undefined` keys dropped. Byte-identical to the gateway's
|
|
9
|
+
* `canonicalJson` (gateway/src/goal-contract.ts), so the base64 we send and the
|
|
10
|
+
* sha we print match what the gateway seals.
|
|
11
|
+
*/
|
|
12
|
+
export function canonicalJson(value) {
|
|
13
|
+
if (value === null || typeof value !== "object")
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
if (Array.isArray(value))
|
|
16
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
17
|
+
const obj = value;
|
|
18
|
+
const keys = Object.keys(obj)
|
|
19
|
+
.filter((k) => obj[k] !== undefined)
|
|
20
|
+
.sort();
|
|
21
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|