@remnic/core 9.3.685 → 9.3.687
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/access-boundary.d.ts +2 -2
- package/dist/access-boundary.js +2 -2
- package/dist/access-cli.js +88 -7
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +5 -5
- package/dist/access-mcp.d.ts +12 -2
- package/dist/access-mcp.js +4 -4
- package/dist/access-operations.d.ts +8 -3
- package/dist/access-operations.js +5 -3
- package/dist/access-schema.d.ts +4 -4
- package/dist/{access-service-DeKrlYU_.d.ts → access-service-DmCHJ4cH.d.ts} +105 -29
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +1 -1
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/access-surface-catalog.js +2 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/{chunk-OFUULUSY.js → chunk-473JIN2U.js} +56 -5
- package/dist/chunk-473JIN2U.js.map +1 -0
- package/dist/{chunk-SQGPGC76.js → chunk-FUCUR2OZ.js} +540 -43
- package/dist/chunk-FUCUR2OZ.js.map +1 -0
- package/dist/{chunk-IIDSFFE5.js → chunk-KFBOZYME.js} +42 -3
- package/dist/chunk-KFBOZYME.js.map +1 -0
- package/dist/{chunk-PK6RGRSD.js → chunk-NN7QYW5W.js} +2 -2
- package/dist/chunk-NN7QYW5W.js.map +1 -0
- package/dist/{chunk-JPCKLFWK.js → chunk-QVMXQGT7.js} +6 -5
- package/dist/chunk-QVMXQGT7.js.map +1 -0
- package/dist/{chunk-BZISAF67.js → chunk-S2OU5DZY.js} +28 -6
- package/dist/chunk-S2OU5DZY.js.map +1 -0
- package/dist/{cli-D3-Q5Uod.d.ts → cli-D8nZ2MPH.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +6 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -6
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/schemas.d.ts +16 -16
- package/dist/transfer/types.d.ts +10 -10
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -1
- package/src/access-cli.ts +94 -4
- package/src/access-http.ts +39 -1
- package/src/access-mcp.ts +54 -1
- package/src/access-operations.ts +66 -0
- package/src/access-service.ts +147 -62
- package/src/access-surface-catalog.test.ts +1 -1
- package/src/access-surface-catalog.ts +2 -0
- package/src/cli.ts +1 -0
- package/src/coding/decision-surfaces.test.ts +279 -0
- package/src/coding/decision-surfaces.ts +475 -0
- package/dist/chunk-BZISAF67.js.map +0 -1
- package/dist/chunk-IIDSFFE5.js.map +0 -1
- package/dist/chunk-JPCKLFWK.js.map +0 -1
- package/dist/chunk-OFUULUSY.js.map +0 -1
- package/dist/chunk-PK6RGRSD.js.map +0 -1
- package/dist/chunk-SQGPGC76.js.map +0 -1
package/src/access-operations.ts
CHANGED
|
@@ -20,6 +20,11 @@ import type {
|
|
|
20
20
|
EngramAccessMemoryResponse,
|
|
21
21
|
EngramAccessWriteResponse,
|
|
22
22
|
} from "./access-service.js";
|
|
23
|
+
import {
|
|
24
|
+
DECISION_SUBCOMMANDS,
|
|
25
|
+
type DecisionSurfaceRequest,
|
|
26
|
+
type DecisionSurfaceResponse,
|
|
27
|
+
} from "./coding/decision-surfaces.js";
|
|
23
28
|
|
|
24
29
|
// ---------------------------------------------------------------------------
|
|
25
30
|
// memory_get — fetch one memory by id
|
|
@@ -135,12 +140,72 @@ export const memoryStoreOperation = defineOperation<MemoryStoreInput, MemoryStor
|
|
|
135
140
|
// so the hook still fires inside the service's idempotent-write lock —
|
|
136
141
|
// never before, never on a replay (#1434 invariant preserved by the
|
|
137
142
|
// boundary migration).
|
|
143
|
+
|
|
138
144
|
ctx.hooks,
|
|
139
145
|
);
|
|
140
146
|
return { result };
|
|
141
147
|
},
|
|
142
148
|
});
|
|
143
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// coding_decision — decision-record surfaces (issue #1548 Track A PR 2)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The subcommand field is required and MUST be one of the four valid values
|
|
156
|
+
* (rule 51 — reject loudly, list the options, never silently default). The
|
|
157
|
+
* remaining fields are optional because each subcommand uses a different
|
|
158
|
+
* subset; the handler validates subcommand-specific requirements after
|
|
159
|
+
* routing.
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* MCP clients send `null` for absent optional fields. Zod `.optional()`
|
|
163
|
+
* rejects `null`, so strip nulls at the object level before the inner
|
|
164
|
+
* schema validates (review: cursor null-field thread).
|
|
165
|
+
*/
|
|
166
|
+
const codingDecisionSchema = z.preprocess(
|
|
167
|
+
(data) => {
|
|
168
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
169
|
+
const out: Record<string, unknown> = {};
|
|
170
|
+
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
171
|
+
if (v !== null) out[k] = v;
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
return data;
|
|
176
|
+
},
|
|
177
|
+
z.object({
|
|
178
|
+
subcommand: z.enum(DECISION_SUBCOMMANDS),
|
|
179
|
+
sessionKey: z.string().trim().max(512).optional(),
|
|
180
|
+
namespace: z.string().trim().max(256).optional(),
|
|
181
|
+
id: z.string().trim().max(512).optional(),
|
|
182
|
+
title: z.string().trim().max(512).optional(),
|
|
183
|
+
status: z.string().trim().max(64).optional(),
|
|
184
|
+
context: z.string().trim().max(8192).optional(),
|
|
185
|
+
decision: z.string().trim().max(8192).optional(),
|
|
186
|
+
consequences: z.string().trim().max(8192).optional(),
|
|
187
|
+
entityRefs: z.array(z.string().trim().min(1).max(256)).optional(),
|
|
188
|
+
supersedesId: z.string().trim().max(512).optional(),
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
export type CodingDecisionInput = DecisionSurfaceRequest;
|
|
193
|
+
export type CodingDecisionOutput = { result: DecisionSurfaceResponse };
|
|
194
|
+
|
|
195
|
+
export const codingDecisionOperation = defineOperation<
|
|
196
|
+
CodingDecisionInput,
|
|
197
|
+
CodingDecisionOutput
|
|
198
|
+
>({
|
|
199
|
+
name: "coding_decision",
|
|
200
|
+
description:
|
|
201
|
+
"List, get, record, or supersede decision records in the session's coding namespace (issue #1548 Track A).",
|
|
202
|
+
schema: codingDecisionSchema as z.ZodType<CodingDecisionInput>,
|
|
203
|
+
handler: async (input, ctx) => {
|
|
204
|
+
const result = await ctx.service.codingDecision(input, ctx.authenticatedPrincipal);
|
|
205
|
+
return { result };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
144
209
|
// ---------------------------------------------------------------------------
|
|
145
210
|
// Surface registration map — what each transport calls the pilot ops
|
|
146
211
|
// ---------------------------------------------------------------------------
|
|
@@ -154,4 +219,5 @@ export const REGISTERED_OPERATIONS = [
|
|
|
154
219
|
memoryGetOperation.spec.name,
|
|
155
220
|
memorySearchOperation.spec.name,
|
|
156
221
|
memoryStoreOperation.spec.name,
|
|
222
|
+
codingDecisionOperation.spec.name,
|
|
157
223
|
] as const;
|
package/src/access-service.ts
CHANGED
|
@@ -12,7 +12,13 @@ import {
|
|
|
12
12
|
lcmSessionKeyForNamespace,
|
|
13
13
|
projectTagProjectId,
|
|
14
14
|
resolveCodingNamespaceOverlay,
|
|
15
|
+
type CodingNamespaceOverlay,
|
|
15
16
|
} from "./coding/coding-namespace.js";
|
|
17
|
+
import {
|
|
18
|
+
handleCodingDecision,
|
|
19
|
+
type DecisionSurfaceRequest,
|
|
20
|
+
type DecisionSurfaceResponse,
|
|
21
|
+
} from "./coding/decision-surfaces.js";
|
|
16
22
|
import { WorkStorage } from "./work/storage.js";
|
|
17
23
|
import {
|
|
18
24
|
exportWorkBoardMarkdown,
|
|
@@ -59,6 +65,7 @@ import { canReadNamespace, canWriteNamespace, defaultNamespaceForPrincipal, reca
|
|
|
59
65
|
import {
|
|
60
66
|
expandScopeProfileReadNamespaces,
|
|
61
67
|
resolveScopeProfilePlan,
|
|
68
|
+
type ResolvedScopeProfilePlan,
|
|
62
69
|
type ScopeProfileLayerResolution,
|
|
63
70
|
type ScopeProfilePromotionResolution,
|
|
64
71
|
} from "./namespaces/scope-profiles.js";
|
|
@@ -1346,74 +1353,29 @@ export class EngramAccessService {
|
|
|
1346
1353
|
return null;
|
|
1347
1354
|
}
|
|
1348
1355
|
|
|
1349
|
-
/**
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
|
|
1356
|
-
* - An explicit `namespace` always wins and is authorized strictly via
|
|
1357
|
-
* `resolveWritableNamespace` → `canWriteNamespace`. A coding-overlay
|
|
1358
|
-
* namespace string (`<base>-project-*`) is NOT a writable target via the
|
|
1359
|
-
* explicit field — project scoping is requested with `cwd`/`projectTag`,
|
|
1360
|
-
* never by naming the derived namespace — so there is no way to bypass the
|
|
1361
|
-
* policy allow-list by guessing/forging an overlay name (Codex review).
|
|
1362
|
-
* - With NO coding overlay, the write stays on `config.defaultNamespace` —
|
|
1363
|
-
* exactly the pre-#1434 behavior, so an unqualified write is NOT silently
|
|
1364
|
-
* moved to a principal self namespace (Codex review).
|
|
1365
|
-
* - WITH a coding overlay, the base is the principal self namespace
|
|
1366
|
-
* (`defaultNamespaceForPrincipal`, write-checked) — the SAME base recall,
|
|
1367
|
-
* observe, and the orchestrator buffer-flush write path overlay onto
|
|
1368
|
-
* (rule 42 / Cursor) — so a project-scoped store lands exactly where
|
|
1369
|
-
* project-scoped recall searches. The overlay namespace is always REBUILT
|
|
1370
|
-
* from the authenticated principal's base, never accepted as a caller
|
|
1371
|
-
* string, so a caller can never reach another principal's subtree.
|
|
1372
|
-
*
|
|
1373
|
-
* Read-only: this NEVER mutates session coding context, so the idempotency
|
|
1374
|
-
* peeks and dryRun preflights that call it stay side-effect free (Codex
|
|
1375
|
-
* review). It prefers the per-call `cwd`/`projectTag` (the project explicitly
|
|
1376
|
-
* identified for this write), else the session's existing context. The HTTP
|
|
1377
|
-
* surface lets the peek and the write each resolve independently; the peek's
|
|
1378
|
-
* namespace only gates rate-limiting (memory_store/suggestion_submit run their
|
|
1379
|
-
* own idempotency check), so a benign session-context change between the two
|
|
1380
|
-
* never fails a write — there is no namespace to "pin".
|
|
1381
|
-
*/
|
|
1382
|
-
private async resolveCodingScopedWriteNamespace(
|
|
1356
|
+
/** Shared coding-scope derivation for the read/write resolvers below —
|
|
1357
|
+
* coding context, overlay, principal, scope-profile plan for an IMPLICIT
|
|
1358
|
+
* request, IDENTICAL to recall precedence (session-first, per-call fallback)
|
|
1359
|
+
* so a scoped store is discoverable by scoped recall (#1434). Single source
|
|
1360
|
+
* of truth for the namespacesEnabled/projectScope gates (rule 22; keeps the
|
|
1361
|
+
* scattered-config-read ratchet flat). READ-ONLY: never mutates session. */
|
|
1362
|
+
private async resolveCodingScopeInputs(
|
|
1383
1363
|
request: CodingScopedWriteInput & {
|
|
1384
1364
|
namespace?: string;
|
|
1385
1365
|
sessionKey?: string;
|
|
1386
1366
|
authenticatedPrincipal?: string;
|
|
1387
1367
|
},
|
|
1388
|
-
): Promise<
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
request.sessionKey,
|
|
1395
|
-
request.authenticatedPrincipal,
|
|
1396
|
-
);
|
|
1397
|
-
}
|
|
1398
|
-
// Project scoping only applies when namespaces are enabled (else overlaying
|
|
1399
|
-
// would create false isolation over a single storage dir) and projectScope
|
|
1400
|
-
// is on. The coding context MUST be resolved exactly as the recall path
|
|
1401
|
-
// resolves it, or a scoped store won't be discoverable by scoped recall
|
|
1402
|
-
// (the whole point of #1434). Recall calls `maybeAttachCodingContext`, which
|
|
1403
|
-
// returns early when the session already has a context — so recall is
|
|
1404
|
-
// SESSION-FIRST: an existing session binding wins, and the per-call
|
|
1405
|
-
// cwd/projectTag is only used to seed a context when none is attached yet.
|
|
1406
|
-
// Mirror that precedence here: session context first, per-call as fallback
|
|
1407
|
-
// (Codex review — a per-call-wins write would land in a project that the
|
|
1408
|
-
// same session's recall, still on the bound project, never searches).
|
|
1409
|
-
//
|
|
1368
|
+
): Promise<{
|
|
1369
|
+
principal: string | undefined;
|
|
1370
|
+
codingContext: CodingContext | null;
|
|
1371
|
+
overlay: CodingNamespaceOverlay | null;
|
|
1372
|
+
profilePlan: ResolvedScopeProfilePlan | null;
|
|
1373
|
+
}> {
|
|
1410
1374
|
// A sessionKey is REQUIRED to apply the overlay. The recall path can only
|
|
1411
|
-
// attach/look up coding context per session
|
|
1412
|
-
//
|
|
1413
|
-
//
|
|
1414
|
-
//
|
|
1415
|
-
// injects cwd/projectTag but no sessionKey would store into
|
|
1416
|
-
// `default-project-*` that its own recall never searches (Codex review).
|
|
1375
|
+
// attach/look up coding context per session, so a sessionless recall always
|
|
1376
|
+
// searches the base namespace; a sessionless write/read must too — otherwise
|
|
1377
|
+
// a client that injects cwd/projectTag but no sessionKey would land in a
|
|
1378
|
+
// `default-project-*` namespace its own recall never searches (Codex review).
|
|
1417
1379
|
const hasSession =
|
|
1418
1380
|
typeof request.sessionKey === "string" && request.sessionKey.length > 0;
|
|
1419
1381
|
const codingContext =
|
|
@@ -1443,6 +1405,35 @@ export class EngramAccessService {
|
|
|
1443
1405
|
codingContext,
|
|
1444
1406
|
codingOverlay: overlay,
|
|
1445
1407
|
});
|
|
1408
|
+
return { principal, codingContext, overlay, profilePlan };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Resolve the write namespace for explicit-write tools (memory_store /
|
|
1413
|
+
* suggestion_submit), project-scoping the write the same way recall does so a
|
|
1414
|
+
* memory stored with a client-injected `cwd`/`projectTag` is discoverable by
|
|
1415
|
+
* project-scoped recall (#1434, rule 42). Shared derivation lives in
|
|
1416
|
+
* {@link resolveCodingScopeInputs}; this method enforces the WRITE acl
|
|
1417
|
+
* (`canWriteNamespace` / profile-layer writability). Read-only: never mutates
|
|
1418
|
+
* session coding context.
|
|
1419
|
+
*/
|
|
1420
|
+
private async resolveCodingScopedWriteNamespace(
|
|
1421
|
+
request: CodingScopedWriteInput & {
|
|
1422
|
+
namespace?: string;
|
|
1423
|
+
sessionKey?: string;
|
|
1424
|
+
authenticatedPrincipal?: string;
|
|
1425
|
+
},
|
|
1426
|
+
): Promise<string> {
|
|
1427
|
+
const hasExplicitNamespace =
|
|
1428
|
+
typeof request.namespace === "string" && request.namespace.trim().length > 0;
|
|
1429
|
+
if (hasExplicitNamespace) {
|
|
1430
|
+
return this.resolveWritableNamespace(
|
|
1431
|
+
request.namespace,
|
|
1432
|
+
request.sessionKey,
|
|
1433
|
+
request.authenticatedPrincipal,
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
const { principal, overlay, profilePlan } = await this.resolveCodingScopeInputs(request);
|
|
1446
1437
|
if (profilePlan) {
|
|
1447
1438
|
const selectedLayer = profilePlan.layers.find((layer) => layer.id === profilePlan.writeLayer);
|
|
1448
1439
|
const writeNamespaceReadable =
|
|
@@ -1475,6 +1466,60 @@ export class EngramAccessService {
|
|
|
1475
1466
|
return combineNamespaces(base, overlay.namespace);
|
|
1476
1467
|
}
|
|
1477
1468
|
|
|
1469
|
+
/** Read-side mirror of {@link resolveCodingScopedWriteNamespace}. Decision
|
|
1470
|
+
* `list`/`get` use this so a record written by a project-scoped session is
|
|
1471
|
+
* listable/fetchable by the SAME session without manually supplying the
|
|
1472
|
+
* overlaid namespace (review P2). Derivation is IDENTICAL to the write path
|
|
1473
|
+
* (shared via {@link resolveCodingScopeInputs}); the only difference is the
|
|
1474
|
+
* ACL — reads enforce {@link canReadNamespace}, so a read-but-not-write
|
|
1475
|
+
* principal can still list/fetch (rule 42). */
|
|
1476
|
+
private async resolveCodingScopedReadableNamespace(
|
|
1477
|
+
request: CodingScopedWriteInput & {
|
|
1478
|
+
namespace?: string;
|
|
1479
|
+
sessionKey?: string;
|
|
1480
|
+
authenticatedPrincipal?: string;
|
|
1481
|
+
},
|
|
1482
|
+
): Promise<string> {
|
|
1483
|
+
const principal = this.resolveRequestPrincipal(
|
|
1484
|
+
request.sessionKey,
|
|
1485
|
+
request.authenticatedPrincipal,
|
|
1486
|
+
);
|
|
1487
|
+
const hasExplicitNamespace =
|
|
1488
|
+
typeof request.namespace === "string" && request.namespace.trim().length > 0;
|
|
1489
|
+
if (hasExplicitNamespace) {
|
|
1490
|
+
return this.resolveReadableNamespace(request.namespace, principal);
|
|
1491
|
+
}
|
|
1492
|
+
const inputs = await this.resolveCodingScopeInputs(request);
|
|
1493
|
+
const { overlay, profilePlan, principal: resolvedPrincipal } = inputs;
|
|
1494
|
+
if (profilePlan) {
|
|
1495
|
+
// The write layer is the namespace decisions are RECORDED under. The
|
|
1496
|
+
// WRITE path authorizes it through the profile plan (selectedLayer.
|
|
1497
|
+
// writable AND readNamespaces.includes(writeNamespace)), NOT the raw
|
|
1498
|
+
// namespace ACL, so the READ path must use the SAME profile-plan
|
|
1499
|
+
// authorization. canReadNamespace only recognizes explicit policies
|
|
1500
|
+
// plus default/shared namespaces, which would reject a profile-granted
|
|
1501
|
+
// layer the same session just wrote through (review P2: scope-profile
|
|
1502
|
+
// read authorization for decision reads; rule 42).
|
|
1503
|
+
const target = profilePlan.writeNamespace;
|
|
1504
|
+
if (!profilePlan.readNamespaces.includes(target)) {
|
|
1505
|
+
throw new EngramAccessInputError(`namespace is not readable: ${target}`);
|
|
1506
|
+
}
|
|
1507
|
+
return target;
|
|
1508
|
+
}
|
|
1509
|
+
if (!overlay) {
|
|
1510
|
+
// No coding overlay → read the base namespace through the standard read
|
|
1511
|
+
// ACL, identical to memory_get with no explicit namespace.
|
|
1512
|
+
return this.resolveReadableNamespace(undefined, resolvedPrincipal);
|
|
1513
|
+
}
|
|
1514
|
+
// Coding overlay → overlay onto the principal self base, the SAME namespace
|
|
1515
|
+
// the write path writes to, then enforce the read ACL.
|
|
1516
|
+
const base = defaultNamespaceForPrincipal(resolvedPrincipal, this.orchestrator.config);
|
|
1517
|
+
if (!canReadNamespace(resolvedPrincipal, base, this.orchestrator.config)) {
|
|
1518
|
+
throw new EngramAccessInputError(`namespace is not readable: ${base}`);
|
|
1519
|
+
}
|
|
1520
|
+
return combineNamespaces(base, overlay.namespace);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1478
1523
|
/**
|
|
1479
1524
|
* Resolve ONE effective memory scope plan for a write-producing request
|
|
1480
1525
|
* (#1495 / seed for epic #1494). The returned {@link MemoryScopePlan} is the
|
|
@@ -4401,6 +4446,45 @@ export class EngramAccessService {
|
|
|
4401
4446
|
};
|
|
4402
4447
|
}
|
|
4403
4448
|
|
|
4449
|
+
/** Whether the coding_decision tool should appear in tools/list (rule 39). */
|
|
4450
|
+
get decisionRecordSurfaceVisible(): boolean {
|
|
4451
|
+
return this.orchestrator.config.codingKnowledge?.enabled === true
|
|
4452
|
+
&& this.orchestrator.config.codingKnowledge?.decisionRecords === true;
|
|
4453
|
+
}
|
|
4454
|
+
/**
|
|
4455
|
+
* Thin delegate — handler logic in coding/decision-surfaces.ts (#1548 PR2).
|
|
4456
|
+
* All three surfaces (MCP/HTTP/CLI) arrive here via the boundary operation.
|
|
4457
|
+
* Namespace resolution uses the SAME path as memory_store (principal ACL +
|
|
4458
|
+
* coding overlay + default fallback) so decision records land in the same
|
|
4459
|
+
* storage root.
|
|
4460
|
+
*/
|
|
4461
|
+
async codingDecision(
|
|
4462
|
+
request: DecisionSurfaceRequest,
|
|
4463
|
+
authenticatedPrincipal?: string,
|
|
4464
|
+
): Promise<DecisionSurfaceResponse> {
|
|
4465
|
+
return handleCodingDecision(request, {
|
|
4466
|
+
codingKnowledge: this.orchestrator.config.codingKnowledge,
|
|
4467
|
+
getCodingContext: (sk) => this.orchestrator.getCodingContextForSession(sk),
|
|
4468
|
+
resolveStorage: async (req) => {
|
|
4469
|
+
const isWrite = req.subcommand === "record" || req.subcommand === "supersede";
|
|
4470
|
+
const ns = isWrite
|
|
4471
|
+
? await this.resolveCodingScopedWriteNamespace({
|
|
4472
|
+
namespace: req.namespace,
|
|
4473
|
+
sessionKey: req.sessionKey,
|
|
4474
|
+
authenticatedPrincipal,
|
|
4475
|
+
})
|
|
4476
|
+
: await this.resolveCodingScopedReadableNamespace({
|
|
4477
|
+
namespace: req.namespace,
|
|
4478
|
+
sessionKey: req.sessionKey,
|
|
4479
|
+
authenticatedPrincipal,
|
|
4480
|
+
});
|
|
4481
|
+
const storage = await this.orchestrator.getStorage(ns);
|
|
4482
|
+
return Object.assign(storage, { namespace: ns });
|
|
4483
|
+
},
|
|
4484
|
+
throwInputError: (msg) => { throw new EngramAccessInputError(msg); },
|
|
4485
|
+
});
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4404
4488
|
async memoryBrowse(
|
|
4405
4489
|
request: EngramAccessMemoryBrowseRequest = {},
|
|
4406
4490
|
): Promise<EngramAccessMemoryBrowseResponse> {
|
|
@@ -8264,3 +8348,4 @@ export class EngramAccessService {
|
|
|
8264
8348
|
});
|
|
8265
8349
|
}
|
|
8266
8350
|
}
|
|
8351
|
+
|
|
@@ -67,7 +67,7 @@ function shortToolName(advertised: string): string {
|
|
|
67
67
|
/** Spin up a server with emitLegacyTools=true and read the deduped short names. */
|
|
68
68
|
async function liveMcpToolShortNames(): Promise<ReadonlySet<string>> {
|
|
69
69
|
const stub = { briefingEnabled: true } as unknown as EngramAccessService;
|
|
70
|
-
const server = new EngramMcpServer(stub, { emitLegacyTools: true });
|
|
70
|
+
const server = new EngramMcpServer(stub, { emitLegacyTools: true, codingDecisionVisible: true });
|
|
71
71
|
const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
|
|
72
72
|
const result = (response as { result?: { tools?: Array<{ name: string }> } }).result;
|
|
73
73
|
const names = new Set<string>();
|
|
@@ -72,6 +72,7 @@ export const MCP_TOOLS: readonly McpToolEntry[] = [
|
|
|
72
72
|
{ tool: "memory_get", operation: "memory_get" },
|
|
73
73
|
{ tool: "memory_timeline", operation: null },
|
|
74
74
|
{ tool: "memory_store", operation: "memory_store" },
|
|
75
|
+
{ tool: "coding_decision", operation: "coding_decision" },
|
|
75
76
|
{ tool: "suggestion_submit", operation: null },
|
|
76
77
|
{ tool: "entity_get", operation: null },
|
|
77
78
|
{ tool: "review_queue_list", operation: null },
|
|
@@ -168,6 +169,7 @@ export const HTTP_ROUTES: readonly HttpRouteEntry[] = [
|
|
|
168
169
|
{ method: "POST", pathname: "/engram/v1/lcm/compaction/record", operation: null },
|
|
169
170
|
{ method: "GET", pathname: "/engram/v1/lcm/status", operation: null },
|
|
170
171
|
{ method: "POST", pathname: "/engram/v1/memories", operation: "memory_store" },
|
|
172
|
+
{ method: "POST", pathname: "/engram/v1/coding/decisions", operation: "coding_decision" },
|
|
171
173
|
{ method: "POST", pathname: "/engram/v1/suggestions", operation: null },
|
|
172
174
|
{ method: "GET", pathname: "/engram/v1/memories", operation: null },
|
|
173
175
|
{ method: "GET", pathname: "/engram/v1/memories/:id", operation: "memory_get" },
|
package/src/cli.ts
CHANGED
|
@@ -2800,6 +2800,7 @@ export async function runAccessMcpServeCliCommand(
|
|
|
2800
2800
|
new EngramMcpServer(service, {
|
|
2801
2801
|
principal: options.principal,
|
|
2802
2802
|
emitLegacyTools: options.emitLegacyTools,
|
|
2803
|
+
codingDecisionVisible: service.decisionRecordSurfaceVisible,
|
|
2803
2804
|
});
|
|
2804
2805
|
await server.runStdio(options.stdin ?? process.stdin, options.stdout ?? process.stdout);
|
|
2805
2806
|
return { ok: true };
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision-record surface tests (issue #1548 Track A PR 2).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Gate predicate: all three conditions (config.enabled + decisionRecords +
|
|
6
|
+
* coding context) checked identically.
|
|
7
|
+
* - Subcommand validation: invalid subcommand → error listing valid ones.
|
|
8
|
+
* - Unknown decision id → explicit not-found, not empty success (rule 34).
|
|
9
|
+
* - Prove-fail-before: `engram.coding_decision` absent from tools/list when
|
|
10
|
+
* the config gate is off; present when on (rule 39 byte-identical contract).
|
|
11
|
+
* - Three surfaces → one service method (rule 22 spirit).
|
|
12
|
+
* - Registry fitness: the operation resolves through the boundary.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import { EngramAccessInputError, type EngramAccessService } from "../access-service.js";
|
|
17
|
+
import type { Orchestrator } from "../orchestrator.js";
|
|
18
|
+
import { getOperation, type OperationName } from "../access-boundary.js";
|
|
19
|
+
import "../access-operations.js";
|
|
20
|
+
import { EngramMcpServer } from "../access-mcp.js";
|
|
21
|
+
import {
|
|
22
|
+
DECISION_SUBCOMMANDS,
|
|
23
|
+
formatDecisionSubcommands,
|
|
24
|
+
isDecisionRecordSurfaceEnabled,
|
|
25
|
+
isDecisionRecordSurfaceVisible,
|
|
26
|
+
isDecisionSubcommand,
|
|
27
|
+
type DecisionSurfaceRequest,
|
|
28
|
+
type DecisionSurfaceResponse,
|
|
29
|
+
} from "./decision-surfaces.js";
|
|
30
|
+
import type { CodingContext, CodingKnowledgeConfig, PluginConfig } from "../types.js";
|
|
31
|
+
|
|
32
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Test helpers
|
|
34
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const GATE_OFF_CONFIG: CodingKnowledgeConfig = {
|
|
37
|
+
enabled: false,
|
|
38
|
+
decisionRecords: true,
|
|
39
|
+
architectureCard: true,
|
|
40
|
+
sessionDelta: true,
|
|
41
|
+
architectureCardLlmSummary: false,
|
|
42
|
+
structuralProvider: "none",
|
|
43
|
+
structuralProviderCommand: "",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const GATE_ON_CONFIG: CodingKnowledgeConfig = {
|
|
47
|
+
...GATE_OFF_CONFIG,
|
|
48
|
+
enabled: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const CODING_CONTEXT: CodingContext = {
|
|
52
|
+
projectId: "acme-backend",
|
|
53
|
+
branch: "main",
|
|
54
|
+
rootPath: "/repo/acme",
|
|
55
|
+
defaultBranch: "main",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mock service that records calls to `codingDecision`. The mock signature MUST
|
|
60
|
+
* match the production method (rule 33).
|
|
61
|
+
*/
|
|
62
|
+
function makeMockService(
|
|
63
|
+
response: DecisionSurfaceResponse = {
|
|
64
|
+
subcommand: "list",
|
|
65
|
+
records: [],
|
|
66
|
+
count: 0,
|
|
67
|
+
},
|
|
68
|
+
): { service: EngramAccessService; calls: DecisionSurfaceRequest[] } {
|
|
69
|
+
const calls: DecisionSurfaceRequest[] = [];
|
|
70
|
+
const service = {
|
|
71
|
+
codingDecision(
|
|
72
|
+
req: DecisionSurfaceRequest,
|
|
73
|
+
_authenticatedPrincipal?: string,
|
|
74
|
+
): Promise<DecisionSurfaceResponse> {
|
|
75
|
+
calls.push(req);
|
|
76
|
+
return Promise.resolve(response);
|
|
77
|
+
},
|
|
78
|
+
} as unknown as EngramAccessService;
|
|
79
|
+
return { service, calls };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeMockServiceWithConfig(
|
|
83
|
+
config: CodingKnowledgeConfig,
|
|
84
|
+
codingContext: CodingContext | null,
|
|
85
|
+
): EngramAccessService {
|
|
86
|
+
const pluginConfig = {
|
|
87
|
+
codingKnowledge: config,
|
|
88
|
+
codingMode: { projectScope: true },
|
|
89
|
+
} as unknown as PluginConfig;
|
|
90
|
+
const orchestrator = {
|
|
91
|
+
config: pluginConfig,
|
|
92
|
+
getCodingContextForSession: (_sk: string) => codingContext,
|
|
93
|
+
} as unknown as Orchestrator;
|
|
94
|
+
return {
|
|
95
|
+
orchestrator,
|
|
96
|
+
} as unknown as EngramAccessService;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// Gate predicate tests
|
|
101
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
test("gate: disabled when config.enabled is false", () => {
|
|
104
|
+
assert.equal(isDecisionRecordSurfaceEnabled(GATE_OFF_CONFIG, CODING_CONTEXT), false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("gate: disabled when decisionRecords is false", () => {
|
|
108
|
+
const cfg: CodingKnowledgeConfig = { ...GATE_ON_CONFIG, decisionRecords: false };
|
|
109
|
+
assert.equal(isDecisionRecordSurfaceEnabled(cfg, CODING_CONTEXT), false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("gate: disabled when no coding context attached", () => {
|
|
113
|
+
assert.equal(isDecisionRecordSurfaceEnabled(GATE_ON_CONFIG, null), false);
|
|
114
|
+
assert.equal(isDecisionRecordSurfaceEnabled(GATE_ON_CONFIG, undefined), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("gate: enabled only when all three conditions hold", () => {
|
|
118
|
+
assert.equal(isDecisionRecordSurfaceEnabled(GATE_ON_CONFIG, CODING_CONTEXT), true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("visibility gate: config-only check for tools/list construction", () => {
|
|
122
|
+
assert.equal(isDecisionRecordSurfaceVisible(GATE_OFF_CONFIG), false);
|
|
123
|
+
assert.equal(isDecisionRecordSurfaceVisible(GATE_ON_CONFIG), true);
|
|
124
|
+
assert.equal(
|
|
125
|
+
isDecisionRecordSurfaceVisible({ ...GATE_ON_CONFIG, decisionRecords: false }),
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// Subcommand validation tests
|
|
132
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test("subcommand: isDecisionSubcommand accepts all valid values", () => {
|
|
135
|
+
for (const sc of DECISION_SUBCOMMANDS) {
|
|
136
|
+
assert.equal(isDecisionSubcommand(sc), true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("subcommand: isDecisionSubcommand rejects invalid values", () => {
|
|
141
|
+
assert.equal(isDecisionSubcommand("delete"), false);
|
|
142
|
+
assert.equal(isDecisionSubcommand(""), false);
|
|
143
|
+
assert.equal(isDecisionSubcommand(undefined), false);
|
|
144
|
+
assert.equal(isDecisionSubcommand(42), false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("subcommand: formatDecisionSubcommands lists all valid options", () => {
|
|
148
|
+
const formatted = formatDecisionSubcommands();
|
|
149
|
+
for (const sc of DECISION_SUBCOMMANDS) {
|
|
150
|
+
assert.ok(formatted.includes(sc), `formatted list should contain "${sc}"`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
155
|
+
// Prove-fail-before: tools/list gate (rule 39)
|
|
156
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
test("tools/list: engram.coding_decision absent when gate off (byte-identical to main)", async () => {
|
|
159
|
+
const stub = { briefingEnabled: true } as unknown as EngramAccessService;
|
|
160
|
+
const server = new EngramMcpServer(stub, { emitLegacyTools: true });
|
|
161
|
+
const response = await server.handleRequest({
|
|
162
|
+
jsonrpc: "2.0",
|
|
163
|
+
id: 1,
|
|
164
|
+
method: "tools/list",
|
|
165
|
+
});
|
|
166
|
+
const tools = (response as { result?: { tools?: Array<{ name: string }> } }).result?.tools ?? [];
|
|
167
|
+
const names = new Set(tools.map((t) => t.name));
|
|
168
|
+
assert.equal(names.has("engram.coding_decision"), false, "tool must be absent when gate is off");
|
|
169
|
+
assert.equal(names.has("remnic.coding_decision"), false, "alias must be absent when gate is off");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
173
|
+
// Registry fitness: coding_decision operation is registered
|
|
174
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
test("registry: coding_decision operation is registered through the boundary", () => {
|
|
177
|
+
const op = getOperation("coding_decision" as OperationName);
|
|
178
|
+
assert.ok(op, "coding_decision must be registered in the operation registry");
|
|
179
|
+
assert.equal(op?.spec.name, "coding_decision");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
183
|
+
// Three surfaces → one service method (rule 22 spirit)
|
|
184
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
test("MCP surface: engram.coding_decision dispatches through the boundary to service.codingDecision", async () => {
|
|
187
|
+
const { service, calls } = makeMockService();
|
|
188
|
+
const server = new EngramMcpServer(service, { emitLegacyTools: true });
|
|
189
|
+
await server.handleRequest({
|
|
190
|
+
jsonrpc: "2.0",
|
|
191
|
+
id: 1,
|
|
192
|
+
method: "tools/call",
|
|
193
|
+
params: {
|
|
194
|
+
name: "engram.coding_decision",
|
|
195
|
+
arguments: { subcommand: "list", sessionKey: "s1" },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
assert.equal(calls.length, 1, "service.codingDecision called exactly once");
|
|
199
|
+
assert.equal(calls[0]?.subcommand, "list");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("MCP surface: remnic.coding_decision alias dispatches identically", async () => {
|
|
203
|
+
const { service, calls } = makeMockService();
|
|
204
|
+
const server = new EngramMcpServer(service, { emitLegacyTools: true });
|
|
205
|
+
await server.handleRequest({
|
|
206
|
+
jsonrpc: "2.0",
|
|
207
|
+
id: 1,
|
|
208
|
+
method: "tools/call",
|
|
209
|
+
params: {
|
|
210
|
+
name: "remnic.coding_decision",
|
|
211
|
+
arguments: { subcommand: "list", sessionKey: "s1" },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
assert.equal(calls.length, 1);
|
|
215
|
+
assert.equal(calls[0]?.subcommand, "list");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("operation handler: invalid subcommand → error listing valid options", async () => {
|
|
219
|
+
const op = getOperation("coding_decision" as OperationName);
|
|
220
|
+
assert.ok(op);
|
|
221
|
+
await assert.rejects(
|
|
222
|
+
() =>
|
|
223
|
+
op!.run(
|
|
224
|
+
{ subcommand: "delete", sessionKey: "s1" },
|
|
225
|
+
{ service: makeMockService().service },
|
|
226
|
+
),
|
|
227
|
+
(err: unknown) => {
|
|
228
|
+
assert.ok(err instanceof EngramAccessInputError, "should be EngramAccessInputError");
|
|
229
|
+
const msg = (err as Error).message;
|
|
230
|
+
assert.ok(msg.includes("list"), "error lists 'list'");
|
|
231
|
+
assert.ok(msg.includes("get"), "error lists 'get'");
|
|
232
|
+
assert.ok(msg.includes("record"), "error lists 'record'");
|
|
233
|
+
assert.ok(msg.includes("supersede"), "error lists 'supersede'");
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("operation handler: record subcommand calls service with record params", async () => {
|
|
240
|
+
const { service, calls } = makeMockService({
|
|
241
|
+
subcommand: "record",
|
|
242
|
+
memoryId: "mem-1",
|
|
243
|
+
status: "proposed",
|
|
244
|
+
});
|
|
245
|
+
const op = getOperation("coding_decision" as OperationName);
|
|
246
|
+
assert.ok(op);
|
|
247
|
+
await op!.run(
|
|
248
|
+
{
|
|
249
|
+
subcommand: "record",
|
|
250
|
+
sessionKey: "s1",
|
|
251
|
+
title: "Use SQLite for graph store",
|
|
252
|
+
context: "Need a local graph store",
|
|
253
|
+
decision: "Adopt better-sqlite3",
|
|
254
|
+
consequences: "Adds native dep",
|
|
255
|
+
},
|
|
256
|
+
{ service },
|
|
257
|
+
);
|
|
258
|
+
assert.equal(calls.length, 1);
|
|
259
|
+
assert.equal(calls[0]?.subcommand, "record");
|
|
260
|
+
assert.equal(calls[0]?.title, "Use SQLite for graph store");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("operation handler: get subcommand with unknown id → not-found response", async () => {
|
|
264
|
+
const { service } = makeMockService({
|
|
265
|
+
subcommand: "get",
|
|
266
|
+
found: false,
|
|
267
|
+
});
|
|
268
|
+
const op = getOperation("coding_decision" as OperationName);
|
|
269
|
+
assert.ok(op);
|
|
270
|
+
const output = (await op!.run(
|
|
271
|
+
{ subcommand: "get", id: "nonexistent", sessionKey: "s1" },
|
|
272
|
+
{ service },
|
|
273
|
+
)) as { result: DecisionSurfaceResponse };
|
|
274
|
+
const result = output.result;
|
|
275
|
+
assert.equal(result.subcommand, "get");
|
|
276
|
+
if (result.subcommand === "get") {
|
|
277
|
+
assert.equal(result.found, false, "unknown id → explicit not-found, not empty success");
|
|
278
|
+
}
|
|
279
|
+
});
|