@gethmy/mcp 2.4.7 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.4.7",
3
+ "version": "2.5.1",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "src",
28
+ "!src/__tests__",
28
29
  "README.md"
29
30
  ],
30
31
  "repository": {
@@ -54,12 +55,12 @@
54
55
  "bun": ">=1.0.0"
55
56
  },
56
57
  "scripts": {
57
- "build": "bun build src/index.ts src/cli.ts --outdir dist --target node && bun build src/api-client.ts src/config.ts --outdir dist/lib --root src --target node",
58
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
58
59
  "build:bun": "bun build src/index.ts src/http.ts src/remote.ts src/cli.ts --outdir dist --target bun",
59
60
  "serve:remote": "bun src/remote.ts",
60
61
  "dev": "bun --watch src/index.ts",
61
62
  "test": "bun run test:unit && bun run test:integration",
62
- "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts",
63
+ "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
63
64
  "test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
64
65
  "typecheck": "tsc --noEmit",
65
66
  "prepublishOnly": "bun run build"
package/src/api-client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { getDisplayLinkType } from "@harmony/shared";
1
2
  import { getApiKey, getApiUrl } from "./config.js";
2
3
 
3
4
  export interface ApiResponse<T = unknown> {
@@ -642,34 +643,6 @@ export class HarmonyApiClient {
642
643
  return this.request("GET", `/cards/${cardId}/agent-context${query}`);
643
644
  }
644
645
 
645
- // ============ AGENT PERFORMANCE PROFILES ============
646
-
647
- async getAgentProfile(
648
- workspaceId: string,
649
- agentIdentifier: string,
650
- ): Promise<{ profile: unknown }> {
651
- const params = new URLSearchParams({
652
- workspace_id: workspaceId,
653
- agent_identifier: agentIdentifier,
654
- });
655
- return this.request("GET", `/agent-profiles?${params.toString()}`);
656
- }
657
-
658
- async listAgentProfiles(
659
- workspaceId: string,
660
- ): Promise<{ profiles: unknown[] }> {
661
- const params = new URLSearchParams({ workspace_id: workspaceId });
662
- return this.request("GET", `/agent-profiles?${params.toString()}`);
663
- }
664
-
665
- async refreshAgentProfiles(
666
- workspaceId: string,
667
- ): Promise<{ refreshed: boolean }> {
668
- return this.request("POST", "/agent-profiles/refresh", {
669
- workspace_id: workspaceId,
670
- });
671
- }
672
-
673
646
  // ============ MEMORY OPERATIONS ============
674
647
 
675
648
  async createMemoryEntity(data: {
@@ -682,6 +655,7 @@ export class HarmonyApiClient {
682
655
  content: string;
683
656
  metadata?: Record<string, unknown>;
684
657
  confidence?: number;
658
+ importance?: number;
685
659
  tags?: string[];
686
660
  agent_identifier?: string;
687
661
  }): Promise<{ entity: unknown; warnings?: string[] }> {
@@ -699,6 +673,7 @@ export class HarmonyApiClient {
699
673
  q?: string;
700
674
  limit?: number;
701
675
  offset?: number;
676
+ include_superseded?: boolean;
702
677
  }): Promise<{ entities: unknown[]; count: number }> {
703
678
  const params = new URLSearchParams();
704
679
  params.set("workspace_id", options.workspace_id);
@@ -714,6 +689,7 @@ export class HarmonyApiClient {
714
689
  if (options.limit !== undefined) params.set("limit", String(options.limit));
715
690
  if (options.offset !== undefined)
716
691
  params.set("offset", String(options.offset));
692
+ if (options.include_superseded) params.set("include_superseded", "true");
717
693
  return this.request("GET", `/memory/entities?${params.toString()}`);
718
694
  }
719
695
 
@@ -732,6 +708,10 @@ export class HarmonyApiClient {
732
708
  scope?: string;
733
709
  type?: string;
734
710
  memory_tier?: string;
711
+ // AGP lifecycle fields. Backend may not yet whitelist these — extra keys
712
+ // are dropped server-side, leaving the call as a no-op for those fields.
713
+ superseded_by?: string | null;
714
+ version?: number;
735
715
  },
736
716
  ): Promise<{ entity: unknown; warnings?: string[] }> {
737
717
  return this.request("PUT", `/memory/entities/${entityId}`, updates);
@@ -1067,6 +1047,44 @@ export class HarmonyApiClient {
1067
1047
  return this.request("POST", "/api-keys", { name });
1068
1048
  }
1069
1049
 
1050
+ // ============ PROMPT HISTORY (AGP P2) ============
1051
+
1052
+ async recordPromptHistory(data: {
1053
+ cardId: string;
1054
+ generatedPrompt: string;
1055
+ variant: "analysis" | "draft" | "execute";
1056
+ contextIncluded?: Record<string, unknown>;
1057
+ sessionId?: string | null;
1058
+ contentHash?: string;
1059
+ templateVersion?: number;
1060
+ confidence?: number;
1061
+ templateId?: string | null;
1062
+ isPinned?: boolean;
1063
+ }): Promise<{ entry: unknown }> {
1064
+ return this.request("POST", "/prompt-history", data);
1065
+ }
1066
+
1067
+ async recordPromptHistoryFeedback(
1068
+ sessionId: string,
1069
+ outcome: "success" | "blocker" | "neutral",
1070
+ ): Promise<{ adjusted: number }> {
1071
+ return this.request("POST", "/prompt-history/feedback", {
1072
+ sessionId,
1073
+ outcome,
1074
+ });
1075
+ }
1076
+
1077
+ async getPromptHistoryCohort(contentHash: string): Promise<{
1078
+ cohort: Array<{
1079
+ status: string | null;
1080
+ progressPercent: number | null;
1081
+ hadBlockers: boolean;
1082
+ }>;
1083
+ }> {
1084
+ const params = new URLSearchParams({ content_hash: contentHash });
1085
+ return this.request("GET", `/prompt-history/cohort?${params.toString()}`);
1086
+ }
1087
+
1070
1088
  // ============ PROMPT GENERATION ============
1071
1089
 
1072
1090
  /**
@@ -1087,6 +1105,8 @@ export class HarmonyApiClient {
1087
1105
  includeLinks: boolean;
1088
1106
  includeDescription: boolean;
1089
1107
  }>;
1108
+ /** Optional active session ID to associate with the prompt snapshot. */
1109
+ sessionId?: string | null;
1090
1110
  }): Promise<{
1091
1111
  prompt: string;
1092
1112
  variant: string;
@@ -1105,14 +1125,42 @@ export class HarmonyApiClient {
1105
1125
  cardId: string;
1106
1126
  shortId: number;
1107
1127
  title: string;
1128
+ /** Local UUID identifying the persisted snapshot (AGP P2). */
1129
+ promptId: string;
1130
+ /** SHA-256 of the generated prompt body (AGP P2 cohort key). */
1131
+ contentHash: string;
1132
+ /** Template version that produced this prompt. */
1133
+ version: number;
1108
1134
  }> {
1109
- const { assembleContext, cacheManifest, generatePrompt } =
1110
- await loadPromptModules();
1135
+ const { generatePrompt } = await loadPromptModules();
1111
1136
 
1112
1137
  // Fetch card data
1113
1138
  const cardResult = await this.getCard(options.cardId);
1114
1139
  const cardData = cardResult.card as CardPromptData;
1115
1140
 
1141
+ // Fetch card reference links so the prompt can render the "Related Cards"
1142
+ // section and the blocker-aware "Recommended Next Step" synthesis.
1143
+ // Best-effort — link fetch failures must not break prompt generation.
1144
+ try {
1145
+ const linksResult = await this.getCardLinks(options.cardId);
1146
+ const rawLinks =
1147
+ (linksResult.links as Array<{
1148
+ link_type: "relates_to" | "blocks" | "duplicates" | "is_part_of";
1149
+ direction: "outgoing" | "incoming";
1150
+ target_card: { short_id: number; title: string } | null;
1151
+ }>) || [];
1152
+ cardData.links = rawLinks
1153
+ .filter((l) => l.target_card)
1154
+ .map((l) => ({
1155
+ target_card: l.target_card as { short_id: number; title: string },
1156
+ direction: l.direction,
1157
+ display_type: getDisplayLinkType(l.link_type, l.direction),
1158
+ }));
1159
+ } catch (err) {
1160
+ const msg = err instanceof Error ? err.message : String(err);
1161
+ console.debug(`[generateCardPrompt] getCardLinks failed: ${msg}`);
1162
+ }
1163
+
1116
1164
  // Try to get column info
1117
1165
  let columnData: { name: string } | null = null;
1118
1166
  const projectIdForBoard = options.projectId || cardData.project_id;
@@ -1132,67 +1180,37 @@ export class HarmonyApiClient {
1132
1180
 
1133
1181
  const variant = options.variant || "execute";
1134
1182
 
1135
- // Assemble memory context
1136
- let assembledContextStr: string | undefined;
1137
- let assemblyId: string | undefined;
1183
+ // Phase 0 (memory architecture v2): full context assembly removed.
1184
+ // Use the basic memory search path so callers still get _some_ memory
1185
+ // hints. Phase 1 will reintroduce a session-scoped working memory layer.
1186
+ const assembledContextStr: string | undefined = undefined;
1187
+ const assemblyId: string | undefined = undefined;
1138
1188
  let memories: MemoryItem[] | undefined;
1139
1189
 
1140
1190
  try {
1141
1191
  if (options.workspaceId && cardData.title) {
1142
- const cardLabels = (cardData.labels || []).map((l) => l.name);
1143
- const taskContext = [cardData.title, cardData.description || ""]
1144
- .filter(Boolean)
1145
- .join(" ");
1146
-
1147
- const assembled = await assembleContext({
1148
- workspaceId: options.workspaceId,
1149
- projectId: options.projectId,
1150
- taskContext,
1151
- cardLabels,
1152
- cardId: cardData.id,
1153
- client: this,
1154
- });
1155
-
1156
- if (assembled.context) {
1157
- assembledContextStr = assembled.context;
1158
- assemblyId = assembled.manifest.assemblyId;
1159
- cacheManifest(assembled.manifest);
1192
+ const memoryResult = await this.searchMemoryEntities(
1193
+ options.workspaceId,
1194
+ cardData.title,
1195
+ {
1196
+ project_id: options.projectId,
1197
+ limit: 5,
1198
+ },
1199
+ );
1200
+ if (memoryResult.entities?.length > 0) {
1201
+ memories = (memoryResult.entities as MemoryItem[]).map((e) => ({
1202
+ id: e.id,
1203
+ type: e.type,
1204
+ title: e.title,
1205
+ content: e.content,
1206
+ confidence: e.confidence,
1207
+ tags: e.tags || [],
1208
+ }));
1160
1209
  }
1161
1210
  }
1162
1211
  } catch (err) {
1163
- // Context assembly failed, try legacy fallback
1164
1212
  const msg = err instanceof Error ? err.message : String(err);
1165
- console.debug(`[generateCardPrompt] Context assembly failed: ${msg}`);
1166
- try {
1167
- if (options.workspaceId && cardData.title) {
1168
- const memoryResult = await this.searchMemoryEntities(
1169
- options.workspaceId,
1170
- cardData.title,
1171
- {
1172
- project_id: options.projectId,
1173
- limit: 5,
1174
- },
1175
- );
1176
- if (memoryResult.entities?.length > 0) {
1177
- memories = (memoryResult.entities as MemoryItem[]).map((e) => ({
1178
- id: e.id,
1179
- type: e.type,
1180
- title: e.title,
1181
- content: e.content,
1182
- confidence: e.confidence,
1183
- tags: e.tags || [],
1184
- }));
1185
- }
1186
- }
1187
- } catch (fallbackErr) {
1188
- const fallbackMsg =
1189
- fallbackErr instanceof Error
1190
- ? fallbackErr.message
1191
- : String(fallbackErr);
1192
- console.debug(
1193
- `[generateCardPrompt] Memory fallback also failed: ${fallbackMsg}`,
1194
- );
1195
- }
1213
+ console.debug(`[generateCardPrompt] Memory search failed: ${msg}`);
1196
1214
  }
1197
1215
 
1198
1216
  const result = generatePrompt({
@@ -1206,6 +1224,30 @@ export class HarmonyApiClient {
1206
1224
  assemblyId,
1207
1225
  });
1208
1226
 
1227
+ // AGP P2: persist a session-linked snapshot. Best-effort — never fail
1228
+ // prompt generation just because logging didn't land.
1229
+ try {
1230
+ await this.recordPromptHistory({
1231
+ cardId: cardData.id,
1232
+ generatedPrompt: result.prompt,
1233
+ variant: variant as "analysis" | "draft" | "execute",
1234
+ contextIncluded: {
1235
+ assemblyId: result.assemblyId ?? null,
1236
+ tokenEstimate: result.tokenEstimate,
1237
+ contextSummary: result.contextSummary,
1238
+ },
1239
+ sessionId: options.sessionId ?? null,
1240
+ contentHash: result.contentHash,
1241
+ templateVersion: result.version,
1242
+ confidence: 0.5,
1243
+ });
1244
+ } catch (err) {
1245
+ const msg = err instanceof Error ? err.message : String(err);
1246
+ console.debug(
1247
+ `[generateCardPrompt] prompt_history persistence failed: ${msg}`,
1248
+ );
1249
+ }
1250
+
1209
1251
  return {
1210
1252
  ...result,
1211
1253
  cardId: cardData.id,
@@ -1245,14 +1287,10 @@ interface MemoryItem {
1245
1287
  tags: string[];
1246
1288
  }
1247
1289
 
1248
- // Cached dynamic imports for context-assembly and prompt-builder
1290
+ // Cached dynamic import for prompt-builder.
1291
+ // Phase 0 (memory architecture v2): context-assembly module deleted; prompt
1292
+ // generation falls back to a basic memory search path until Phase 1.
1249
1293
  let _promptModules: {
1250
- assembleContext: Awaited<
1251
- typeof import("./context-assembly.js")
1252
- >["assembleContext"];
1253
- cacheManifest: Awaited<
1254
- typeof import("./context-assembly.js")
1255
- >["cacheManifest"];
1256
1294
  generatePrompt: Awaited<
1257
1295
  typeof import("./prompt-builder.js")
1258
1296
  >["generatePrompt"];
@@ -1260,13 +1298,8 @@ let _promptModules: {
1260
1298
 
1261
1299
  async function loadPromptModules() {
1262
1300
  if (!_promptModules) {
1263
- const [ca, pb] = await Promise.all([
1264
- import("./context-assembly.js"),
1265
- import("./prompt-builder.js"),
1266
- ]);
1301
+ const pb = await import("./prompt-builder.js");
1267
1302
  _promptModules = {
1268
- assembleContext: ca.assembleContext,
1269
- cacheManifest: ca.cacheManifest,
1270
1303
  generatePrompt: pb.generatePrompt,
1271
1304
  };
1272
1305
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Memory Utility Floor — write admission control.
3
+ *
4
+ * Implements §4.5.1 of docs/superpowers/plans/2026-05-07-memory-architecture-v2.md.
5
+ *
6
+ * The Floor is deterministic, regex + length based. No LLM calls.
7
+ * It runs at every write site (`harmony_remember` and any future Phase 3
8
+ * write-gate output). Rejected writes return a structured error explaining
9
+ * which rule fired so callers can adjust.
10
+ *
11
+ * Bypasses:
12
+ * - `source_trust='document'` rows skip the title-quality regex (legitimate
13
+ * long-form prose isn't tag-concat slop). Other rules still apply.
14
+ * - Session-scope memories (`scope` starts with `session:`) skip the Floor
15
+ * entirely — they're explicitly ephemeral.
16
+ */
17
+
18
+ const STOP_WORDS = new Set([
19
+ "a",
20
+ "an",
21
+ "and",
22
+ "are",
23
+ "as",
24
+ "at",
25
+ "be",
26
+ "by",
27
+ "for",
28
+ "from",
29
+ "has",
30
+ "have",
31
+ "he",
32
+ "in",
33
+ "is",
34
+ "it",
35
+ "its",
36
+ "of",
37
+ "on",
38
+ "that",
39
+ "the",
40
+ "this",
41
+ "to",
42
+ "was",
43
+ "were",
44
+ "will",
45
+ "with",
46
+ ]);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Rule registry — mirrors §4.5.1
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export type FloorRuleId =
53
+ | "tag-concat"
54
+ | "tag-concat-slash"
55
+ | "frequency-meta"
56
+ | "self-referential"
57
+ | "bare-type-prefix"
58
+ | "specificity-floor"
59
+ | "length-floor"
60
+ | "operational-data-ban";
61
+
62
+ export interface FloorRejection {
63
+ rule: FloorRuleId;
64
+ message: string;
65
+ }
66
+
67
+ interface FloorInput {
68
+ title: string;
69
+ content: string;
70
+ type: string;
71
+ scope?: string;
72
+ source_trust?: string;
73
+ tags?: string[];
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Patterns
78
+ // ---------------------------------------------------------------------------
79
+
80
+ // "Consolidated procedure: procedure, add, user" — comma-separated tag concat
81
+ const TAG_CONCAT_COMMA =
82
+ /^(Pattern|Procedure|Lesson|Consolidated [a-z]+):\s+[a-z]+(?:,\s*[a-z]+){1,5}$/i;
83
+
84
+ // "Procedure: Procedure / Card / Mobile / Native" — slash-separated tag concat
85
+ const TAG_CONCAT_SLASH =
86
+ /^(Pattern|Procedure|Lesson):\s+[a-z]+(?:\s*\/\s*[a-z]+){2,5}$/i;
87
+
88
+ // "Pattern: recurring procedure (17 instances)"
89
+ const FREQUENCY_META = /^Pattern:\s+recurring\s+\w+\s+\(\d+\s+instances?\)$/i;
90
+
91
+ // "Type:" / "Memory:" with no real content after the colon
92
+ const BARE_TYPE_PREFIX = /^(Type|Memory):\s*$/i;
93
+
94
+ // Self-referential: mentions the memory implementation
95
+ const SELF_REFERENTIAL =
96
+ /\b(memory system|consolidation|harmony_(remember|recall|consolidate|audit|promote|forget|cleanup|backfill|prune)|context.assembly|context-assembly|AGP|active.learning|active-learning|knowledge_entities)\b/i;
97
+
98
+ // Implementation-detail leaking into a lesson body
99
+ const IMPL_DETAIL =
100
+ /^\s*(?:[A-Z][^.]*[.,]\s*)?\b(blocks?|prevents?|broken|in.memory only|fixme|todo)\b/i;
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Helpers
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function wordCount(s: string): number {
107
+ return s
108
+ .split(/\s+/)
109
+ .map((w) => w.replace(/[^\w]/g, "").toLowerCase())
110
+ .filter((w) => w.length > 0 && !STOP_WORDS.has(w)).length;
111
+ }
112
+
113
+ function hasProperNounOrIdentifier(s: string): boolean {
114
+ // Capitalized word in the middle of the title (not just sentence start)
115
+ // OR snake_case / camelCase / PascalCase identifier OR backtick-fenced ident.
116
+ if (/`[^`]+`/.test(s)) return true;
117
+ if (/\b[A-Z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*/.test(s)) return true; // PascalCase / camelCase with caps
118
+ if (/\b[a-z]+(?:_[a-z]+){1,}/.test(s)) return true; // snake_case
119
+ // Capitalized non-first word
120
+ const tokens = s.split(/\s+/);
121
+ for (let i = 1; i < tokens.length; i++) {
122
+ const t = tokens[i] ?? "";
123
+ if (/^[A-Z][a-z]+/.test(t)) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Public API
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Validate a memory write against the Utility Floor. Returns null if the
134
+ * write passes; otherwise returns a structured rejection.
135
+ *
136
+ * Order is deterministic and matches §4.5.1 numbering:
137
+ * 1. Title quality gate (tag-concat, frequency-meta, bare-prefix)
138
+ * 2. Self-referential gate
139
+ * 3. Implementation-detail gate (lessons only)
140
+ * 4. Specificity floor
141
+ * 5. Length floor
142
+ * 6. Operational-data ban
143
+ *
144
+ * Bypasses:
145
+ * - `source_trust === 'document'` skips rule 1 (title checks).
146
+ * - `scope` starts with `session:` skips all rules — session memories are
147
+ * explicitly ephemeral.
148
+ */
149
+ export function validateMemoryQuality(
150
+ input: FloorInput,
151
+ ): FloorRejection | null {
152
+ const title = (input.title ?? "").trim();
153
+ const content = (input.content ?? "").trim();
154
+ const isDocSource = input.source_trust === "document";
155
+ const isSessionScope =
156
+ typeof input.scope === "string" && input.scope.startsWith("session:");
157
+
158
+ // Bypass: session-scoped memories are exempt entirely.
159
+ if (isSessionScope) return null;
160
+
161
+ // Rule 6: Operational-data ban (always applies; even before bypass below).
162
+ // Agent profiling was removed in #195; reject any attempt to revive the dump pattern.
163
+ if (input.type === "agent" && /^Agent Profile:\s*/i.test(title)) {
164
+ return {
165
+ rule: "operational-data-ban",
166
+ message:
167
+ "Agent Profile entries are not allowed in knowledge_entities (operational data, not knowledge).",
168
+ };
169
+ }
170
+
171
+ // Rule 1: Title quality (skipped for doc-source imports).
172
+ if (!isDocSource) {
173
+ if (TAG_CONCAT_COMMA.test(title)) {
174
+ return {
175
+ rule: "tag-concat",
176
+ message:
177
+ "Title is a comma-separated tag concatenation. Provide a content-bearing title (a real sentence, file path, or symbol).",
178
+ };
179
+ }
180
+ if (TAG_CONCAT_SLASH.test(title)) {
181
+ return {
182
+ rule: "tag-concat-slash",
183
+ message:
184
+ "Title is a slash-separated tag concatenation. Provide a content-bearing title.",
185
+ };
186
+ }
187
+ if (FREQUENCY_META.test(title)) {
188
+ return {
189
+ rule: "frequency-meta",
190
+ message:
191
+ 'Frequency-meta titles ("Pattern: recurring X (N instances)") are not retrievable knowledge. Access counts live in knowledge_entity_audit_log, not in entities.',
192
+ };
193
+ }
194
+ if (BARE_TYPE_PREFIX.test(title)) {
195
+ return {
196
+ rule: "bare-type-prefix",
197
+ message:
198
+ 'Bare "Type:" / "Memory:" prefix without a content-bearing title. Use the type column; put substance in the title.',
199
+ };
200
+ }
201
+ }
202
+
203
+ // Rule 2: Self-referential gate (always — except when explicitly typed
204
+ // 'meta' which is excluded from default retrieval).
205
+ if (input.type !== "meta") {
206
+ if (SELF_REFERENTIAL.test(title) || SELF_REFERENTIAL.test(content)) {
207
+ return {
208
+ rule: "self-referential",
209
+ message:
210
+ "Memory references the memory implementation (operational noise). Operational notes belong in code comments or commit messages, not in retrievable memories.",
211
+ };
212
+ }
213
+ }
214
+
215
+ // Rule 3: Implementation-detail-as-lesson gate.
216
+ if (input.type === "lesson" && IMPL_DETAIL.test(content)) {
217
+ return {
218
+ rule: "impl-detail-lesson",
219
+ message:
220
+ "Lesson body reads as an implementation TODO/issue, not a learning. Convert to a positive takeaway or move to a code comment.",
221
+ } as FloorRejection;
222
+ }
223
+
224
+ // Rule 4: Specificity floor (skipped for doc-source imports — they may
225
+ // legitimately have short heading-style titles).
226
+ if (!isDocSource) {
227
+ const titleWordCount = title
228
+ .split(/\s+/)
229
+ .filter((w) => w.length > 0).length;
230
+ if (titleWordCount < 4 && !hasProperNounOrIdentifier(title)) {
231
+ return {
232
+ rule: "specificity-floor",
233
+ message:
234
+ "Title is too generic — needs ≥4 words OR a proper noun / specific identifier (file path, symbol, etc).",
235
+ };
236
+ }
237
+ }
238
+
239
+ // Rule 5: Length floor.
240
+ if (content.length < 40) {
241
+ return {
242
+ rule: "length-floor",
243
+ message: `Content is too short (${content.length} chars; minimum 40).`,
244
+ };
245
+ }
246
+ if (wordCount(content) < 8) {
247
+ return {
248
+ rule: "length-floor",
249
+ message:
250
+ "Content is too short after stop-word removal (minimum 8 substantive words).",
251
+ };
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Test whether an entity's stored shape matches Floor-violating patterns.
259
+ * Used by retrieval-time retroactive filter (§4.5.2) and by the Phase 0
260
+ * cleanup script.
261
+ */
262
+ export function classifyExisting(input: FloorInput): FloorRejection | null {
263
+ return validateMemoryQuality(input);
264
+ }