@remnic/core 9.3.685 → 9.3.686
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 +38 -38
- package/dist/transfer/types.d.ts +22 -22
- 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
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision-record surface contract + handler (issue #1548 Track A PR 2).
|
|
3
|
+
*
|
|
4
|
+
* Rule 39: one gate predicate, checked identically on every surface. Rule 22
|
|
5
|
+
* spirit: one implementation behind three thin wirings. The service holds
|
|
6
|
+
* only a thin delegate that builds a {@link DecisionSurfaceContext} and calls
|
|
7
|
+
* {@link handleCodingDecision}; the handler logic lives here so the
|
|
8
|
+
* access-service god file gains thin wiring only.
|
|
9
|
+
*
|
|
10
|
+
* No orchestrator imports (rule 11 — no shared mutable state). No circular
|
|
11
|
+
* dependency on access-service.ts: validation errors are thrown via
|
|
12
|
+
* `ctx.throwInputError`, which the service wires to EngramAccessInputError.
|
|
13
|
+
*/
|
|
14
|
+
import type { CodingKnowledgeConfig, CodingContext, MemoryFile, MemoryFrontmatter, MemoryStatus } from "../types.js";
|
|
15
|
+
import {
|
|
16
|
+
ACTIVE_DECISION_STATUSES,
|
|
17
|
+
DEFAULT_DECISION_STATUS,
|
|
18
|
+
isDecisionStatus,
|
|
19
|
+
parseDecisionRecord,
|
|
20
|
+
serializeDecisionRecord,
|
|
21
|
+
type DecisionRecord,
|
|
22
|
+
type DecisionStatus,
|
|
23
|
+
} from "./decision-records.js";
|
|
24
|
+
import { log } from "../logger.js";
|
|
25
|
+
|
|
26
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Subcommands
|
|
28
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const DECISION_SUBCOMMANDS = [
|
|
31
|
+
"list",
|
|
32
|
+
"get",
|
|
33
|
+
"record",
|
|
34
|
+
"supersede",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
export type DecisionSubcommand = (typeof DECISION_SUBCOMMANDS)[number];
|
|
38
|
+
|
|
39
|
+
const SUBCOMMAND_VALUES = DECISION_SUBCOMMANDS as readonly string[];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Type guard — narrows an unknown subcommand string to the
|
|
43
|
+
* {@link DecisionSubcommand} union.
|
|
44
|
+
*/
|
|
45
|
+
export function isDecisionSubcommand(value: unknown): value is DecisionSubcommand {
|
|
46
|
+
return typeof value === "string" && SUBCOMMAND_VALUES.includes(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Human-readable subcommand list for error messages (rule 51 — list valid
|
|
51
|
+
* options so the caller can correct rather than guess).
|
|
52
|
+
*/
|
|
53
|
+
export function formatDecisionSubcommands(): string {
|
|
54
|
+
return DECISION_SUBCOMMANDS.join(", ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Gate predicate — rule 39: one predicate, identical on every surface
|
|
59
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The single decision-record surface gate. Returns `true` only when:
|
|
63
|
+
* 1. `codingKnowledge.enabled` is on (the master Track A gate),
|
|
64
|
+
* 2. `codingKnowledge.decisionRecords` is on (the feature switch), AND
|
|
65
|
+
* 3. A coding context is attached (the session is project/branch scoped —
|
|
66
|
+
* decision records live *in* the coding namespace, rule 42).
|
|
67
|
+
*
|
|
68
|
+
* Every surface — MCP `engram.coding_decision`, HTTP
|
|
69
|
+
* `POST /engram/v1/coding/decisions`, CLI `engram-access decision` — MUST call
|
|
70
|
+
* this predicate (or the handler that embeds it) before dispatching. The
|
|
71
|
+
* tool-visibility gate in the MCP constructor checks conditions 1–2 only
|
|
72
|
+
* (coding context is per-session and cannot be evaluated at construction
|
|
73
|
+
* time); the call-time gate checks all three.
|
|
74
|
+
*/
|
|
75
|
+
export function isDecisionRecordSurfaceEnabled(
|
|
76
|
+
config: CodingKnowledgeConfig,
|
|
77
|
+
codingContext: CodingContext | null | undefined,
|
|
78
|
+
): boolean {
|
|
79
|
+
return (
|
|
80
|
+
config.enabled === true &&
|
|
81
|
+
config.decisionRecords === true &&
|
|
82
|
+
codingContext != null
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Config-only visibility gate — used by the MCP constructor to decide whether
|
|
88
|
+
* to advertise `engram.coding_decision` in `tools/list`. When this returns
|
|
89
|
+
* `false` the tools array is byte-identical to pre-feature (rule 39).
|
|
90
|
+
*/
|
|
91
|
+
export function isDecisionRecordSurfaceVisible(
|
|
92
|
+
config: CodingKnowledgeConfig,
|
|
93
|
+
): boolean {
|
|
94
|
+
return config.enabled === true && config.decisionRecords === true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
98
|
+
// Surface request / response shapes
|
|
99
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Canonical surface request — one shape for all three transports. The
|
|
103
|
+
* `subcommand` field selects which operation runs; the remaining fields are
|
|
104
|
+
* optional depending on the subcommand.
|
|
105
|
+
*
|
|
106
|
+
* `sessionKey` identifies the session whose coding context scopes the
|
|
107
|
+
* operation. `namespace` overrides the coding-scoped namespace (same
|
|
108
|
+
* precedence as `memory_store` — explicit namespace wins).
|
|
109
|
+
*/
|
|
110
|
+
export interface DecisionSurfaceRequest {
|
|
111
|
+
subcommand: DecisionSubcommand;
|
|
112
|
+
sessionKey?: string;
|
|
113
|
+
namespace?: string;
|
|
114
|
+
// get / supersede
|
|
115
|
+
id?: string;
|
|
116
|
+
// record
|
|
117
|
+
title?: string;
|
|
118
|
+
status?: string;
|
|
119
|
+
context?: string;
|
|
120
|
+
decision?: string;
|
|
121
|
+
consequences?: string;
|
|
122
|
+
entityRefs?: string[];
|
|
123
|
+
// supersede
|
|
124
|
+
supersedesId?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Surface response — a discriminated union on `subcommand`. Each surface
|
|
129
|
+
* serializes this to its transport-appropriate shape.
|
|
130
|
+
*/
|
|
131
|
+
export type DecisionSurfaceResponse =
|
|
132
|
+
| { subcommand: "list"; records: DecisionSurfaceRecord[]; count: number }
|
|
133
|
+
| { subcommand: "get"; found: boolean; record?: DecisionSurfaceRecord }
|
|
134
|
+
| { subcommand: "record"; memoryId: string; status: string }
|
|
135
|
+
| {
|
|
136
|
+
subcommand: "supersede";
|
|
137
|
+
supersededMemoryId: string;
|
|
138
|
+
replacementMemoryId: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Flattened record projection surfaced to clients. Stored as markdown +
|
|
143
|
+
* frontmatter memory files (category `"decision"`) — this shape is the
|
|
144
|
+
* read-side projection, not the storage format.
|
|
145
|
+
*/
|
|
146
|
+
export interface DecisionSurfaceRecord {
|
|
147
|
+
id: string;
|
|
148
|
+
title: string;
|
|
149
|
+
status: string;
|
|
150
|
+
context?: string;
|
|
151
|
+
decision?: string;
|
|
152
|
+
consequences?: string;
|
|
153
|
+
entityRefs: string[];
|
|
154
|
+
supersedes?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
158
|
+
// Handler — the single implementation behind all three surfaces (rule 22)
|
|
159
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Structural subset of StorageManager the decision handler reads or writes.
|
|
163
|
+
* Kept narrow so the module stays decoupled from storage.ts and is
|
|
164
|
+
* unit-testable with a stub.
|
|
165
|
+
*/
|
|
166
|
+
export interface DecisionSurfaceStorage {
|
|
167
|
+
readonly dir: string;
|
|
168
|
+
/** The resolved namespace — used for catalog write recording. */
|
|
169
|
+
readonly namespace: string;
|
|
170
|
+
readAllMemories(): Promise<readonly MemoryFile[]>;
|
|
171
|
+
getMemoryById(id: string): Promise<MemoryFile | null>;
|
|
172
|
+
writeMemory(
|
|
173
|
+
category: "decision",
|
|
174
|
+
content: string,
|
|
175
|
+
options: {
|
|
176
|
+
confidence?: number;
|
|
177
|
+
tags?: string[];
|
|
178
|
+
source?: string;
|
|
179
|
+
/** Outer memory lifecycle status — set to "archived" for inactive
|
|
180
|
+
* decisions so generic recall/search/maintenance exclude them
|
|
181
|
+
* (review P2: persist inactive decision statuses in frontmatter). */
|
|
182
|
+
status?: MemoryStatus;
|
|
183
|
+
/** Decision-specific lifecycle marker, mirrored from the serialized
|
|
184
|
+
* body so the list/get projection has one authoritative source. */
|
|
185
|
+
structuredAttributes?: Record<string, string>;
|
|
186
|
+
},
|
|
187
|
+
): Promise<string>;
|
|
188
|
+
writeMemoryFrontmatter(
|
|
189
|
+
memory: MemoryFile,
|
|
190
|
+
patch: Partial<MemoryFrontmatter>,
|
|
191
|
+
): Promise<unknown>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Dependencies the handler borrows from the service. The service constructs
|
|
196
|
+
* this context per call; the handler never touches the orchestrator directly.
|
|
197
|
+
* `throwInputError` lets the handler raise the surface-appropriate error
|
|
198
|
+
* class without importing access-service.ts (no circular dependency).
|
|
199
|
+
*/
|
|
200
|
+
export interface DecisionSurfaceContext {
|
|
201
|
+
readonly codingKnowledge: CodingKnowledgeConfig;
|
|
202
|
+
getCodingContext(sessionKey: string): CodingContext | null;
|
|
203
|
+
/** Resolve storage through the SAME namespace path as memory_store
|
|
204
|
+
* (principal ACL + coding overlay + default fallback). The #1522 storage
|
|
205
|
+
* chokepoint records the catalog write automatically on every
|
|
206
|
+
* storage.writeMemory, so the handler does NOT touch the catalog itself. */
|
|
207
|
+
resolveStorage(request: DecisionSurfaceRequest): Promise<DecisionSurfaceStorage>;
|
|
208
|
+
/** Throw the surface-appropriate input-validation error. */
|
|
209
|
+
throwInputError(message: string): never;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The single shared implementation behind the MCP, HTTP, and CLI
|
|
214
|
+
* decision-record surfaces. All three transports dispatch through the
|
|
215
|
+
* `coding_decision` boundary operation, which calls this function via the
|
|
216
|
+
* service delegate.
|
|
217
|
+
*
|
|
218
|
+
* Gate (rule 39): `codingKnowledge.enabled + decisionRecords + coding
|
|
219
|
+
* context`. Persistence (rule 43): records are written through the storage
|
|
220
|
+
* manager's normal persist pipeline with category `"decision"` — no direct
|
|
221
|
+
* `fs` writes of memory content. Supersede (rule 25): the replacement is
|
|
222
|
+
* written BEFORE the old record's `structuredAttributes.decisionStatus` is
|
|
223
|
+
* set to `"superseded"` — the structuredAttribute is the authoritative
|
|
224
|
+
* lifecycle marker; content is never rewritten.
|
|
225
|
+
*/
|
|
226
|
+
export async function handleCodingDecision(
|
|
227
|
+
request: DecisionSurfaceRequest,
|
|
228
|
+
ctx: DecisionSurfaceContext,
|
|
229
|
+
): Promise<DecisionSurfaceResponse> {
|
|
230
|
+
const codingContext = request.sessionKey
|
|
231
|
+
? ctx.getCodingContext(request.sessionKey)
|
|
232
|
+
: null;
|
|
233
|
+
if (!isDecisionRecordSurfaceEnabled(ctx.codingKnowledge, codingContext)) {
|
|
234
|
+
ctx.throwInputError(
|
|
235
|
+
"coding_decision requires codingKnowledge.enabled, codingKnowledge.decisionRecords, and an attached coding context",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
switch (request.subcommand) {
|
|
239
|
+
case "list":
|
|
240
|
+
return decisionList(request, ctx);
|
|
241
|
+
case "get":
|
|
242
|
+
return decisionGet(request, ctx);
|
|
243
|
+
case "record":
|
|
244
|
+
return decisionRecord(request, ctx);
|
|
245
|
+
case "supersede":
|
|
246
|
+
return decisionSupersede(request, ctx);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function decisionList(
|
|
251
|
+
request: DecisionSurfaceRequest,
|
|
252
|
+
ctx: DecisionSurfaceContext,
|
|
253
|
+
): Promise<DecisionSurfaceResponse> {
|
|
254
|
+
const storage = await ctx.resolveStorage(request);
|
|
255
|
+
const memories = await storage.readAllMemories();
|
|
256
|
+
const records: DecisionSurfaceRecord[] = [];
|
|
257
|
+
for (const m of memories) {
|
|
258
|
+
if (m.frontmatter.category !== "decision") continue;
|
|
259
|
+
// Exclude lifecycle-retired memories. Any outer frontmatter.status other
|
|
260
|
+
// than undefined/"active" (archived, superseded, forgotten, rejected,
|
|
261
|
+
// quarantined, pending_review) means the generic memory lifecycle has
|
|
262
|
+
// intervened — hide the decision until that resolves. The decision-specific
|
|
263
|
+
// lifecycle marker lives in structuredAttributes.decisionStatus (review:
|
|
264
|
+
// hide all non-active outer statuses from decisions).
|
|
265
|
+
const memStatus = m.frontmatter.status;
|
|
266
|
+
if (memStatus && memStatus !== "active") continue;
|
|
267
|
+
const parsed = safeParseDecisionRecord(m.content);
|
|
268
|
+
if (!parsed) continue;
|
|
269
|
+
const structStatus = m.frontmatter.structuredAttributes?.decisionStatus;
|
|
270
|
+
const effectiveStatus = structStatus ?? parsed.status;
|
|
271
|
+
records.push({
|
|
272
|
+
id: m.frontmatter.id,
|
|
273
|
+
title: parsed.title,
|
|
274
|
+
status: effectiveStatus,
|
|
275
|
+
entityRefs: parsed.entityRefs,
|
|
276
|
+
supersedes: parsed.supersedes,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const visible = records.filter((r) =>
|
|
280
|
+
ACTIVE_DECISION_STATUSES.has(r.status as DecisionStatus),
|
|
281
|
+
);
|
|
282
|
+
return { subcommand: "list", records: visible, count: visible.length };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function decisionGet(
|
|
286
|
+
request: DecisionSurfaceRequest,
|
|
287
|
+
ctx: DecisionSurfaceContext,
|
|
288
|
+
): Promise<DecisionSurfaceResponse> {
|
|
289
|
+
if (!request.id?.trim()) {
|
|
290
|
+
ctx.throwInputError("id is required for the 'get' subcommand");
|
|
291
|
+
}
|
|
292
|
+
const storage = await ctx.resolveStorage(request);
|
|
293
|
+
const memory = await storage.getMemoryById(request.id!);
|
|
294
|
+
if (!memory || memory.frontmatter.category !== "decision") {
|
|
295
|
+
return { subcommand: "get", found: false };
|
|
296
|
+
}
|
|
297
|
+
const parsed = safeParseDecisionRecord(memory.content);
|
|
298
|
+
if (!parsed) {
|
|
299
|
+
return { subcommand: "get", found: false };
|
|
300
|
+
}
|
|
301
|
+
const structStatus = memory.frontmatter.structuredAttributes?.decisionStatus;
|
|
302
|
+
return {
|
|
303
|
+
subcommand: "get",
|
|
304
|
+
found: true,
|
|
305
|
+
record: {
|
|
306
|
+
id: memory.frontmatter.id,
|
|
307
|
+
title: parsed.title,
|
|
308
|
+
status: structStatus ?? parsed.status,
|
|
309
|
+
context: parsed.context,
|
|
310
|
+
decision: parsed.decision,
|
|
311
|
+
consequences: parsed.consequences,
|
|
312
|
+
entityRefs: parsed.entityRefs,
|
|
313
|
+
supersedes: parsed.supersedes,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function decisionRecord(
|
|
319
|
+
request: DecisionSurfaceRequest,
|
|
320
|
+
ctx: DecisionSurfaceContext,
|
|
321
|
+
): Promise<DecisionSurfaceResponse> {
|
|
322
|
+
if (!request.title?.trim()) {
|
|
323
|
+
ctx.throwInputError("title is required for the 'record' subcommand");
|
|
324
|
+
}
|
|
325
|
+
if (!request.decision?.trim()) {
|
|
326
|
+
ctx.throwInputError("decision is required for the 'record' subcommand");
|
|
327
|
+
}
|
|
328
|
+
const status: DecisionStatus = request.status?.trim()
|
|
329
|
+
? isDecisionStatus(request.status)
|
|
330
|
+
? request.status
|
|
331
|
+
: raiseInvalidStatus(request.status, ctx)
|
|
332
|
+
: DEFAULT_DECISION_STATUS;
|
|
333
|
+
const record: DecisionRecord = {
|
|
334
|
+
id: "",
|
|
335
|
+
title: request.title.trim(),
|
|
336
|
+
status,
|
|
337
|
+
context: request.context?.trim() ?? "",
|
|
338
|
+
decision: request.decision.trim(),
|
|
339
|
+
consequences: request.consequences?.trim() ?? "",
|
|
340
|
+
entityRefs: request.entityRefs ?? [],
|
|
341
|
+
};
|
|
342
|
+
const content = serializeDecisionRecord(record);
|
|
343
|
+
const storage = await ctx.resolveStorage(request);
|
|
344
|
+
const isActive = ACTIVE_DECISION_STATUSES.has(status);
|
|
345
|
+
const memoryId = await storage.writeMemory("decision", content, {
|
|
346
|
+
confidence: 1.0,
|
|
347
|
+
tags: ["decision-record"],
|
|
348
|
+
source: "coding-decision",
|
|
349
|
+
// Persist the decision lifecycle in BOTH places so generic
|
|
350
|
+
// recall/search/maintenance (which read frontmatter.status) and the
|
|
351
|
+
// decision list/get projection (which reads structuredAttributes) agree:
|
|
352
|
+
// - structuredAttributes.decisionStatus is the authoritative decision
|
|
353
|
+
// marker, mirrored from the serialized body (one source of truth);
|
|
354
|
+
// - frontmatter.status is set to "archived" for inactive decisions
|
|
355
|
+
// (rejected/superseded) so the outer memory pipeline excludes them
|
|
356
|
+
// from the active corpus exactly like a supersede does (review P2:
|
|
357
|
+
// persist inactive decision statuses in frontmatter).
|
|
358
|
+
structuredAttributes: { decisionStatus: status },
|
|
359
|
+
status: isActive ? undefined : "archived",
|
|
360
|
+
});
|
|
361
|
+
log.info(
|
|
362
|
+
`access-write op=coding_decision/record memoryId=${memoryId} status=${status}`,
|
|
363
|
+
);
|
|
364
|
+
return { subcommand: "record", memoryId, status };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function decisionSupersede(
|
|
368
|
+
request: DecisionSurfaceRequest,
|
|
369
|
+
ctx: DecisionSurfaceContext,
|
|
370
|
+
): Promise<DecisionSurfaceResponse> {
|
|
371
|
+
// The schema advertises `supersedesId` for MCP/HTTP clients that name it
|
|
372
|
+
// explicitly; treat it as an alias for `id` when `id` is absent (review P2).
|
|
373
|
+
const targetId = request.id?.trim() || request.supersedesId?.trim();
|
|
374
|
+
if (!targetId) {
|
|
375
|
+
ctx.throwInputError(
|
|
376
|
+
"id (or supersedesId) is required for the 'supersede' subcommand (the record being superseded)",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (!request.title?.trim()) {
|
|
380
|
+
ctx.throwInputError(
|
|
381
|
+
"title is required for the 'supersede' subcommand (the replacement record)",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
if (!request.decision?.trim()) {
|
|
385
|
+
ctx.throwInputError("decision is required for the 'supersede' subcommand");
|
|
386
|
+
}
|
|
387
|
+
const storage = await ctx.resolveStorage(request);
|
|
388
|
+
const oldMemory = await storage.getMemoryById(targetId);
|
|
389
|
+
if (!oldMemory || oldMemory.frontmatter.category !== "decision") {
|
|
390
|
+
ctx.throwInputError(`decision record not found: ${targetId}`);
|
|
391
|
+
}
|
|
392
|
+
const oldParsed = safeParseDecisionRecord(oldMemory.content);
|
|
393
|
+
if (!oldParsed) {
|
|
394
|
+
ctx.throwInputError(
|
|
395
|
+
`decision record is corrupted and cannot be superseded: ${targetId}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
// Rule 25: write the replacement BEFORE mutating the old record's status.
|
|
399
|
+
const replacement: DecisionRecord = {
|
|
400
|
+
id: "",
|
|
401
|
+
title: request.title.trim(),
|
|
402
|
+
status: "accepted",
|
|
403
|
+
context: request.context?.trim() ?? "",
|
|
404
|
+
decision: request.decision.trim(),
|
|
405
|
+
consequences: request.consequences?.trim() ?? "",
|
|
406
|
+
entityRefs: request.entityRefs ?? [],
|
|
407
|
+
supersedes: targetId,
|
|
408
|
+
};
|
|
409
|
+
const replacementContent = serializeDecisionRecord(replacement);
|
|
410
|
+
const replacementId = await storage.writeMemory(
|
|
411
|
+
"decision",
|
|
412
|
+
replacementContent,
|
|
413
|
+
{
|
|
414
|
+
confidence: 1.0,
|
|
415
|
+
tags: ["decision-record"],
|
|
416
|
+
source: "coding-decision",
|
|
417
|
+
// Mirror decisionRecord: persist structuredAttributes.decisionStatus on
|
|
418
|
+
// the replacement so list/get projection and QMD indexing see the
|
|
419
|
+
// authoritative marker (review: supersede omits decisionStatus attrs).
|
|
420
|
+
structuredAttributes: { decisionStatus: "accepted" },
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
// Mark the old record superseded: set BOTH frontmatter.status (so
|
|
424
|
+
// recall/search/maintenance exclude it from the active corpus — review P2)
|
|
425
|
+
// AND structuredAttributes.decisionStatus (the decision-specific lifecycle
|
|
426
|
+
// marker used by list/get projection). The content body is not mutated.
|
|
427
|
+
// Rule 25: the replacement is written BEFORE the old record is mutated so
|
|
428
|
+
// a frontmatter-write failure leaves a harmless duplicate, not a missing
|
|
429
|
+
// record. Best-effort: log the failure but don't roll back the replacement
|
|
430
|
+
// (review: cursor partial-write thread).
|
|
431
|
+
try {
|
|
432
|
+
await storage.writeMemoryFrontmatter(oldMemory, {
|
|
433
|
+
status: "archived",
|
|
434
|
+
// Refresh the updated timestamp so the archive/supersede lifecycle event
|
|
435
|
+
// and browse/maintenance sort key reflect when the decision was retired,
|
|
436
|
+
// not when it was originally recorded (review: set updated timestamp when
|
|
437
|
+
// retiring old decisions).
|
|
438
|
+
updated: new Date().toISOString(),
|
|
439
|
+
structuredAttributes: {
|
|
440
|
+
...(oldMemory.frontmatter.structuredAttributes ?? {}),
|
|
441
|
+
decisionStatus: "superseded",
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
} catch (err) {
|
|
445
|
+
log.warn(
|
|
446
|
+
`coding_decision/supersede: replacement ${replacementId} written but old record ${targetId} status update failed — old record will still appear until retried: ${err instanceof Error ? err.message : String(err)}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
log.info(
|
|
450
|
+
`access-write op=coding_decision/supersede superseded=${targetId} replacement=${replacementId}`,
|
|
451
|
+
);
|
|
452
|
+
return {
|
|
453
|
+
subcommand: "supersede",
|
|
454
|
+
supersededMemoryId: targetId,
|
|
455
|
+
replacementMemoryId: replacementId,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
460
|
+
// Local helpers
|
|
461
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
function safeParseDecisionRecord(content: string): DecisionRecord | null {
|
|
464
|
+
try {
|
|
465
|
+
return parseDecisionRecord(content);
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function raiseInvalidStatus(value: string, ctx: DecisionSurfaceContext): never {
|
|
472
|
+
ctx.throwInputError(
|
|
473
|
+
`invalid decision status "${value}". Valid options: proposed, accepted, superseded, rejected`,
|
|
474
|
+
);
|
|
475
|
+
}
|