@remnic/core 9.3.669 → 9.3.671

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.
Files changed (218) hide show
  1. package/dist/access-cli.js +16 -16
  2. package/dist/access-http.d.ts +4 -4
  3. package/dist/access-http.js +9 -9
  4. package/dist/access-mcp.d.ts +4 -4
  5. package/dist/access-mcp.js +8 -8
  6. package/dist/{access-service-BCuaiNHa.d.ts → access-service-S9oGKPZc.d.ts} +2 -2
  7. package/dist/access-service.d.ts +4 -4
  8. package/dist/access-service.js +7 -7
  9. package/dist/action-confidence.d.ts +1 -1
  10. package/dist/active-memory-bridge.d.ts +1 -1
  11. package/dist/active-recall.d.ts +1 -1
  12. package/dist/active-recall.js +1 -1
  13. package/dist/behavior-learner.d.ts +1 -1
  14. package/dist/behavior-signals.d.ts +1 -1
  15. package/dist/bootstrap.d.ts +3 -3
  16. package/dist/briefing.d.ts +1 -1
  17. package/dist/briefing.js +3 -3
  18. package/dist/buffer-surprise-report.d.ts +1 -1
  19. package/dist/buffer.d.ts +1 -1
  20. package/dist/calibration.d.ts +1 -1
  21. package/dist/capabilities.d.ts +1 -1
  22. package/dist/causal-behavior.d.ts +1 -1
  23. package/dist/causal-consolidation.d.ts +1 -1
  24. package/dist/causal-consolidation.js +4 -4
  25. package/dist/{chunk-MPXYHC35.js → chunk-32RD3GIW.js} +17 -17
  26. package/dist/{chunk-BQJUPECT.js → chunk-3OKWZT7F.js} +2 -2
  27. package/dist/{chunk-2MXEVL75.js → chunk-6VP3YUCS.js} +2 -2
  28. package/dist/{chunk-WXXLSZHA.js → chunk-ATRB6Q25.js} +2 -2
  29. package/dist/{chunk-KOXGLQS7.js → chunk-B6IUW76R.js} +2 -2
  30. package/dist/{chunk-QHWJG5C5.js → chunk-CPVV2UEL.js} +5 -5
  31. package/dist/{chunk-AARDBQTA.js → chunk-CTCPB57O.js} +2 -2
  32. package/dist/{chunk-WKMCC4NQ.js → chunk-EUM7CZFM.js} +2 -2
  33. package/dist/{chunk-46WUVFOD.js → chunk-F7OWUP3G.js} +4 -4
  34. package/dist/{chunk-AZBV4RRY.js → chunk-FMSDA2D3.js} +1 -1
  35. package/dist/chunk-FMSDA2D3.js.map +1 -0
  36. package/dist/{chunk-SXYCVRLK.js → chunk-GSTYVG5L.js} +3 -3
  37. package/dist/{chunk-XMWF6AU3.js → chunk-LVTTO3VC.js} +2 -2
  38. package/dist/{chunk-4T7P2HLJ.js → chunk-LXVOZ2O6.js} +2 -2
  39. package/dist/{chunk-TJ7HH5LB.js → chunk-LZSMQHXC.js} +2 -2
  40. package/dist/{chunk-CTAV55JM.js → chunk-MLVMBV2C.js} +75 -1
  41. package/dist/chunk-MLVMBV2C.js.map +1 -0
  42. package/dist/{chunk-QZ7ODIVL.js → chunk-MNUPGYIV.js} +2 -2
  43. package/dist/{chunk-MR4PJ277.js → chunk-MTJ2LFAJ.js} +2 -2
  44. package/dist/{chunk-2TCHDANJ.js → chunk-NXBXM7Q6.js} +2 -2
  45. package/dist/{chunk-TIJYQXDI.js → chunk-OHX52AOS.js} +2 -2
  46. package/dist/{chunk-PEPHBH2W.js → chunk-PYTATYUV.js} +2 -2
  47. package/dist/{chunk-UVUTV7CM.js → chunk-TGN4M5MB.js} +7 -7
  48. package/dist/{chunk-JTPXSXHC.js → chunk-V4ZHKCGA.js} +2 -2
  49. package/dist/{chunk-OI4BXFSB.js → chunk-VL5JJOOY.js} +2 -2
  50. package/dist/{chunk-Q2LQZYQ7.js → chunk-Z4GALEO3.js} +3 -3
  51. package/dist/{chunk-A5TEHAR4.js → chunk-ZCVPFDHB.js} +3 -3
  52. package/dist/{chunk-4FJKKC2N.js → chunk-ZI6A7X4V.js} +12 -12
  53. package/dist/chunk-ZI6A7X4V.js.map +1 -0
  54. package/dist/{cli-C98xlwYA.d.ts → cli-B2Ve7R22.d.ts} +3 -3
  55. package/dist/cli.d.ts +5 -5
  56. package/dist/cli.js +21 -21
  57. package/dist/compounding/engine.d.ts +1 -1
  58. package/dist/compounding/engine.js +3 -3
  59. package/dist/compounding/preference-consolidator.d.ts +1 -1
  60. package/dist/compression-optimizer.d.ts +1 -1
  61. package/dist/config.d.ts +1 -1
  62. package/dist/config.js +1 -1
  63. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  64. package/dist/connectors/codex-materialize-runner.js +3 -3
  65. package/dist/connectors/codex-materialize.d.ts +1 -1
  66. package/dist/connectors/index.d.ts +1 -1
  67. package/dist/connectors/index.js +3 -3
  68. package/dist/consolidation-provenance-check.d.ts +1 -1
  69. package/dist/consolidation-undo.d.ts +1 -1
  70. package/dist/contradiction/index.d.ts +1 -1
  71. package/dist/conversation-index/backend.d.ts +1 -1
  72. package/dist/conversation-index/chunker.d.ts +1 -1
  73. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  74. package/dist/conversation-index/indexer.d.ts +1 -1
  75. package/dist/conversation-index/search.d.ts +1 -1
  76. package/dist/day-summary.d.ts +1 -1
  77. package/dist/delinearize.d.ts +1 -1
  78. package/dist/direct-answer-wiring.d.ts +1 -1
  79. package/dist/direct-answer.d.ts +1 -1
  80. package/dist/embedding-fallback.d.ts +1 -1
  81. package/dist/enrichment/index.d.ts +1 -1
  82. package/dist/entity-retrieval.d.ts +1 -1
  83. package/dist/entity-retrieval.js +3 -3
  84. package/dist/entity-schema.d.ts +1 -1
  85. package/dist/explicit-capture.d.ts +3 -3
  86. package/dist/extraction-judge-telemetry.d.ts +1 -1
  87. package/dist/extraction-judge-training.d.ts +1 -1
  88. package/dist/extraction-judge.d.ts +1 -1
  89. package/dist/extraction.d.ts +1 -1
  90. package/dist/fallback-llm.d.ts +1 -1
  91. package/dist/identity-continuity.d.ts +1 -1
  92. package/dist/importance.d.ts +1 -1
  93. package/dist/index.d.ts +8 -8
  94. package/dist/index.js +26 -26
  95. package/dist/intent.d.ts +1 -1
  96. package/dist/lcm/engine.d.ts +1 -1
  97. package/dist/lcm/index.d.ts +1 -1
  98. package/dist/lcm/tools.d.ts +1 -1
  99. package/dist/lifecycle.d.ts +1 -1
  100. package/dist/live-connectors-runner.d.ts +1 -1
  101. package/dist/local-llm.d.ts +1 -1
  102. package/dist/maintenance/memory-governance.d.ts +1 -1
  103. package/dist/maintenance/memory-governance.js +3 -3
  104. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  105. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  106. package/dist/mcp-memory-inspector-app.d.ts +4 -4
  107. package/dist/memory-action-policy.d.ts +1 -1
  108. package/dist/memory-cache.d.ts +1 -1
  109. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  110. package/dist/memory-projection-store.d.ts +1 -1
  111. package/dist/memory-provenance.d.ts +1 -1
  112. package/dist/memory-worth-outcomes.d.ts +1 -1
  113. package/dist/models-json.d.ts +1 -1
  114. package/dist/namespaces/migrate.d.ts +1 -1
  115. package/dist/namespaces/migrate.js +4 -4
  116. package/dist/namespaces/principal.d.ts +1 -1
  117. package/dist/namespaces/search.d.ts +1 -1
  118. package/dist/namespaces/storage.d.ts +1 -1
  119. package/dist/namespaces/storage.js +3 -3
  120. package/dist/native-knowledge.d.ts +1 -1
  121. package/dist/operator-toolkit.d.ts +1 -1
  122. package/dist/operator-toolkit.js +7 -7
  123. package/dist/{orchestrator-DyP9QYsh.d.ts → orchestrator-7zPqGupX.d.ts} +2 -2
  124. package/dist/orchestrator.d.ts +3 -3
  125. package/dist/orchestrator.js +12 -12
  126. package/dist/patterns-cli.d.ts +1 -1
  127. package/dist/policy-runtime.d.ts +1 -1
  128. package/dist/qmd-recall-cache.d.ts +1 -1
  129. package/dist/qmd.d.ts +1 -1
  130. package/dist/recall-disclosure-escalation.d.ts +1 -1
  131. package/dist/recall-explain-renderer.d.ts +1 -1
  132. package/dist/recall-explain-renderer.js +3 -3
  133. package/dist/recall-planner-llm.d.ts +1 -1
  134. package/dist/recall-state.d.ts +1 -1
  135. package/dist/recall-tag-filter.d.ts +1 -1
  136. package/dist/recall-xray-cli.d.ts +1 -1
  137. package/dist/recall-xray-cli.js +4 -4
  138. package/dist/recall-xray-renderer.d.ts +1 -1
  139. package/dist/recall-xray-renderer.js +3 -3
  140. package/dist/recall-xray.d.ts +1 -1
  141. package/dist/recall-xray.js +2 -2
  142. package/dist/resolve-auth-token.d.ts +1 -1
  143. package/dist/resume-bundles.js +2 -2
  144. package/dist/retrieval-agents.d.ts +1 -1
  145. package/dist/retrieval-tiers.d.ts +1 -1
  146. package/dist/routing/engine.d.ts +1 -1
  147. package/dist/routing/store.d.ts +1 -1
  148. package/dist/schemas.d.ts +22 -22
  149. package/dist/search/embed-helper.d.ts +1 -1
  150. package/dist/search/factory.d.ts +1 -1
  151. package/dist/search/index.d.ts +1 -1
  152. package/dist/search/lancedb-backend.d.ts +1 -1
  153. package/dist/search/meilisearch-backend.d.ts +1 -1
  154. package/dist/search/noop-backend.d.ts +1 -1
  155. package/dist/search/orama-backend.d.ts +1 -1
  156. package/dist/search/port.d.ts +1 -1
  157. package/dist/search/remote-backend.d.ts +1 -1
  158. package/dist/{semantic-consolidation-BICZvQ3C.d.ts → semantic-consolidation-BX9Z9_aK.d.ts} +1 -1
  159. package/dist/semantic-consolidation.d.ts +2 -2
  160. package/dist/semantic-consolidation.js +4 -4
  161. package/dist/semantic-rule-promotion.js +3 -3
  162. package/dist/semantic-rule-verifier.d.ts +1 -1
  163. package/dist/semantic-rule-verifier.js +3 -3
  164. package/dist/session-observer-bands.d.ts +1 -1
  165. package/dist/session-observer-state.d.ts +1 -1
  166. package/dist/shared-context/manager.d.ts +1 -1
  167. package/dist/signal.d.ts +1 -1
  168. package/dist/storage.d.ts +1 -1
  169. package/dist/storage.js +2 -2
  170. package/dist/summarizer.d.ts +1 -1
  171. package/dist/summary-snapshot.d.ts +1 -1
  172. package/dist/temporal-supersession.d.ts +1 -1
  173. package/dist/temporal-validity.d.ts +1 -1
  174. package/dist/threading.d.ts +1 -1
  175. package/dist/tier-migration.d.ts +1 -1
  176. package/dist/tier-routing.d.ts +1 -1
  177. package/dist/topics.d.ts +1 -1
  178. package/dist/transcript.d.ts +1 -1
  179. package/dist/transfer/types.d.ts +12 -12
  180. package/dist/{types-D96bCB3C.d.ts → types-D3pm4NhH.d.ts} +30 -1
  181. package/dist/types.d.ts +1 -1
  182. package/dist/types.js +1 -1
  183. package/dist/utility-runtime.d.ts +1 -1
  184. package/dist/verified-recall.js +3 -3
  185. package/package.json +1 -1
  186. package/src/coding/coding-knowledge-config.ts +111 -0
  187. package/src/coding/decision-records.test.ts +434 -0
  188. package/src/coding/decision-records.ts +507 -0
  189. package/src/config.test.ts +118 -0
  190. package/src/config.ts +3 -2
  191. package/src/orchestrator.ts +2 -2
  192. package/src/types.ts +35 -0
  193. package/dist/chunk-4FJKKC2N.js.map +0 -1
  194. package/dist/chunk-AZBV4RRY.js.map +0 -1
  195. package/dist/chunk-CTAV55JM.js.map +0 -1
  196. /package/dist/{chunk-MPXYHC35.js.map → chunk-32RD3GIW.js.map} +0 -0
  197. /package/dist/{chunk-BQJUPECT.js.map → chunk-3OKWZT7F.js.map} +0 -0
  198. /package/dist/{chunk-2MXEVL75.js.map → chunk-6VP3YUCS.js.map} +0 -0
  199. /package/dist/{chunk-WXXLSZHA.js.map → chunk-ATRB6Q25.js.map} +0 -0
  200. /package/dist/{chunk-KOXGLQS7.js.map → chunk-B6IUW76R.js.map} +0 -0
  201. /package/dist/{chunk-QHWJG5C5.js.map → chunk-CPVV2UEL.js.map} +0 -0
  202. /package/dist/{chunk-AARDBQTA.js.map → chunk-CTCPB57O.js.map} +0 -0
  203. /package/dist/{chunk-WKMCC4NQ.js.map → chunk-EUM7CZFM.js.map} +0 -0
  204. /package/dist/{chunk-46WUVFOD.js.map → chunk-F7OWUP3G.js.map} +0 -0
  205. /package/dist/{chunk-SXYCVRLK.js.map → chunk-GSTYVG5L.js.map} +0 -0
  206. /package/dist/{chunk-XMWF6AU3.js.map → chunk-LVTTO3VC.js.map} +0 -0
  207. /package/dist/{chunk-4T7P2HLJ.js.map → chunk-LXVOZ2O6.js.map} +0 -0
  208. /package/dist/{chunk-TJ7HH5LB.js.map → chunk-LZSMQHXC.js.map} +0 -0
  209. /package/dist/{chunk-QZ7ODIVL.js.map → chunk-MNUPGYIV.js.map} +0 -0
  210. /package/dist/{chunk-MR4PJ277.js.map → chunk-MTJ2LFAJ.js.map} +0 -0
  211. /package/dist/{chunk-2TCHDANJ.js.map → chunk-NXBXM7Q6.js.map} +0 -0
  212. /package/dist/{chunk-TIJYQXDI.js.map → chunk-OHX52AOS.js.map} +0 -0
  213. /package/dist/{chunk-PEPHBH2W.js.map → chunk-PYTATYUV.js.map} +0 -0
  214. /package/dist/{chunk-UVUTV7CM.js.map → chunk-TGN4M5MB.js.map} +0 -0
  215. /package/dist/{chunk-JTPXSXHC.js.map → chunk-V4ZHKCGA.js.map} +0 -0
  216. /package/dist/{chunk-OI4BXFSB.js.map → chunk-VL5JJOOY.js.map} +0 -0
  217. /package/dist/{chunk-Q2LQZYQ7.js.map → chunk-Z4GALEO3.js.map} +0 -0
  218. /package/dist/{chunk-A5TEHAR4.js.map → chunk-ZCVPFDHB.js.map} +0 -0
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Decision records — pure storage contract (issue #1548 Track A PR 1).
3
+ *
4
+ * The four-memory-shape memory layer stores architectural decisions as
5
+ * markdown files with YAML frontmatter under the coding namespace. This
6
+ * module is the **pure contract**: data shape, parse, serialise, validate,
7
+ * and the supersede mutation. No filesystem, no orchestrator — callers
8
+ * (PR 2's MCP/HTTP/CLI surfaces and the orchestrator's normal persist
9
+ * pipeline) hang these helpers onto real storage.
10
+ *
11
+ * Why markdown + frontmatter?
12
+ * - QMD searches the body for free (rule 43 — storage chokepoint means the
13
+ * orchestrator persist pipeline fires for catalog + reindex + dedup).
14
+ * - Human-reviewable diff via the same tooling as any other memory page.
15
+ * - The body carries prose; the frontmatter carries the searchable,
16
+ * structured fields.
17
+ *
18
+ * Why this parser/serialiser and not a YAML dep?
19
+ * - The surface used here is intentionally narrow: scalar strings and
20
+ * flow-style arrays of strings. A full YAML dependency is heavier than
21
+ * the parser we need and would unlock a footgun (block-scalar
22
+ * representations, anchors, multiple-document streams) we deliberately
23
+ * want to be impossible.
24
+ *
25
+ * Supersede ordering (rule 25): the replacement record MUST land on disk
26
+ * BEFORE the superseded record's `status` flips, so a process crash between
27
+ * the two writes leaves the new decision discoverable rather than nothing.
28
+ *
29
+ * Pure module — type-only import of the plugin config interface keeps this
30
+ * dependency-free (the entry types live in `../types.ts`).
31
+ */
32
+ import type { CodingKnowledgeConfig } from "../types.js";
33
+
34
+ // ──────────────────────────────────────────────────────────────────────────
35
+ // Public types
36
+ // ──────────────────────────────────────────────────────────────────────────
37
+
38
+ export type DecisionStatus = "proposed" | "accepted" | "superseded" | "rejected";
39
+
40
+ export const DECISION_STATUSES = [
41
+ "proposed",
42
+ "accepted",
43
+ "superseded",
44
+ "rejected",
45
+ ] as const satisfies readonly DecisionStatus[];
46
+
47
+ /**
48
+ * The "active" statuses callers surface in standing-decisions lists, briefing
49
+ * text, and the search-by-default index. Exported as a frozen set so the
50
+ * decision-records tests, the briefing helper, and the future list surface
51
+ * share one declaration (rule 53 — single source of truth for classification).
52
+ *
53
+ * Stand-up note: superseded + rejected are intentionally excluded. A supersede
54
+ * edge (`b.supersedes = "a"`) tells callers what to fall back to.
55
+ */
56
+ export const ACTIVE_DECISION_STATUSES: ReadonlySet<DecisionStatus> = new Set<DecisionStatus>([
57
+ "proposed",
58
+ "accepted",
59
+ ]);
60
+
61
+ /**
62
+ * Least-privileged default for a parsed record whose frontmatter omits
63
+ * `status` (rule 48). "Proposed" — never "accepted" — because accepting a
64
+ * decision is a deliberate operator action.
65
+ */
66
+ export const DEFAULT_DECISION_STATUS: DecisionStatus = "proposed";
67
+
68
+ export interface DecisionRecord {
69
+ /** Stable identifier — typically `ADR-XXXX` or `MADR-XXXX`. Must be unique. */
70
+ id: string;
71
+ /** One-line summary surfaced in briefings and `list` output. */
72
+ title: string;
73
+ /** Status lifecycle (rule 51: only the four declared values). */
74
+ status: DecisionStatus;
75
+ /** The problem / context the decision addresses. */
76
+ context: string;
77
+ /** The decision itself. */
78
+ decision: string;
79
+ /** Trade-offs, follow-ups, consequences. Optional — free-form. */
80
+ consequences: string | undefined;
81
+ /** Entity references (entity IDs, doc anchors, code paths). May be empty. */
82
+ entityRefs: string[];
83
+ /** The record this one supersedes — set by `applySupersede`, never by hand. */
84
+ supersedes?: string;
85
+ }
86
+
87
+ /**
88
+ * Input shape for `serializeDecisionRecord`. Mirrors `DecisionRecord` but
89
+ * keeps `supersedes` reachable so callers writing the replacement before
90
+ * applying supersede can serialise the new record alone.
91
+ */
92
+ export type DecisionRecordInput = Omit<DecisionRecord, "supersedes"> & {
93
+ supersedes?: string;
94
+ };
95
+
96
+ // ──────────────────────────────────────────────────────────────────────────
97
+ // Gate / type guard
98
+ // ──────────────────────────────────────────────────────────────────────────
99
+
100
+ const DECISION_STATUS_BY_VALUE: Record<string, DecisionStatus> = Object.freeze({
101
+ proposed: "proposed",
102
+ accepted: "accepted",
103
+ superseded: "superseded",
104
+ rejected: "rejected",
105
+ }) as Record<string, DecisionStatus>;
106
+
107
+ export function isDecisionStatus(value: unknown): value is DecisionStatus {
108
+ return (
109
+ typeof value === "string"
110
+ && Object.prototype.hasOwnProperty.call(DECISION_STATUS_BY_VALUE, value)
111
+ );
112
+ }
113
+
114
+ const FRONTMATTER_KEYS = Object.freeze([
115
+ "id",
116
+ "title",
117
+ "status",
118
+ "context",
119
+ "decision",
120
+ "consequences",
121
+ "entityRefs",
122
+ "supersedes",
123
+ ] as const satisfies readonly (keyof DecisionRecord | "id")[]);
124
+
125
+ // ──────────────────────────────────────────────────────────────────────────
126
+ // Serialise
127
+ // ──────────────────────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Render a decision record as markdown with a YAML frontmatter block.
131
+ *
132
+ * Frontmatter keys are sorted ascending (rule 38). Strings are double-quoted
133
+ * so reserved YAML characters (`#`, `:`, leading-digits, list-like prefixes)
134
+ * survive a round-trip without the caller caring.
135
+ */
136
+ export function serializeDecisionRecord(record: DecisionRecordInput): string {
137
+ const lines: string[] = ["---"];
138
+ for (const key of FRONTMATTER_KEYS) {
139
+ const present =
140
+ key === "supersedes"
141
+ ? record.supersedes !== undefined
142
+ : key === "consequences"
143
+ ? record.consequences !== undefined
144
+ : true;
145
+ if (!present) continue;
146
+ lines.push(`${key}: ${encodeFrontmatterValue(key, record)}`);
147
+ }
148
+ lines.push("---", "");
149
+ if (record.context) lines.push(record.context);
150
+ lines.push("");
151
+ lines.push("# Decision");
152
+ lines.push("");
153
+ if (record.decision) lines.push(record.decision);
154
+ if (record.consequences !== undefined && record.consequences.length > 0) {
155
+ lines.push("");
156
+ lines.push("# Consequences");
157
+ lines.push("");
158
+ lines.push(record.consequences);
159
+ }
160
+ if (record.entityRefs.length > 0) {
161
+ lines.push("");
162
+ lines.push("# Entity references");
163
+ for (const ref of record.entityRefs) lines.push(`- ${ref}`);
164
+ }
165
+ // Trailing newline keeps the file POSIX-well-formed for `cat`/editors.
166
+ lines.push("");
167
+ return lines.join("\n");
168
+ }
169
+
170
+ function encodeFrontmatterValue(
171
+ key: (typeof FRONTMATTER_KEYS)[number],
172
+ record: DecisionRecordInput,
173
+ ): string {
174
+ if (key === "entityRefs") {
175
+ const refs = record.entityRefs;
176
+ if (refs.length === 0) return "[]";
177
+ const inner = refs.map((ref) => `"${escapeYamlString(ref)}"`).join(", ");
178
+ return `[${inner}]`;
179
+ }
180
+ if (key === "status") return `"${record.status}"`;
181
+ if (key === "supersedes") return `"${record.supersedes ?? ""}"`;
182
+ if (key === "consequences") return `"${escapeYamlString(record.consequences ?? "")}"`;
183
+ const value = record[key];
184
+ return `"${escapeYamlString(typeof value === "string" ? value : String(value))}"`;
185
+ }
186
+
187
+ function escapeYamlString(value: string): string {
188
+ // Inside a double-quoted YAML scalar, escape backslashes and double-quotes
189
+ // AND emit real newlines as a `\n` escape (the parser below mirrors this).
190
+ // Keeping all scalars single-line makes the line-based frontmatter parser
191
+ // safe and the file diff-friendly.
192
+ return value
193
+ .replace(/\\/g, "\\\\")
194
+ .replace(/"/g, '\\"')
195
+ .replace(/\n/g, "\\n");
196
+ }
197
+
198
+ // ──────────────────────────────────────────────────────────────────────────
199
+ // Parse
200
+ // ──────────────────────────────────────────────────────────────────────────
201
+
202
+ interface ParsedFrontmatter {
203
+ readonly fields: Readonly<Record<string, unknown>>;
204
+ readonly body: string;
205
+ }
206
+
207
+ /**
208
+ * Parse a decision record from a markdown string with a YAML frontmatter
209
+ * block. Throws (with a message listing the valid statuses, rule 51) when
210
+ * the document does not match the contract.
211
+ *
212
+ * Missing / undefined `status` defaults to `"proposed"` (rule 48 — least
213
+ * privileged).
214
+ */
215
+ export function parseDecisionRecord(raw: string): DecisionRecord {
216
+ const { fields, body } = extractFrontmatter(raw);
217
+
218
+ // Surface unexpected keys early — silent drift is rule 51's worst case.
219
+ for (const fieldKey of Object.keys(fields)) {
220
+ if (!FRONTMATTER_KEYS.includes(fieldKey as (typeof FRONTMATTER_KEYS)[number])) {
221
+ throw new Error(
222
+ `decision record frontmatter contains unknown key '${fieldKey}'; valid keys are ${FRONTMATTER_KEYS.join(", ")}.`,
223
+ );
224
+ }
225
+ }
226
+
227
+ const id = requireString(fields.id, "id");
228
+ const title = requireString(fields.title, "title");
229
+ const context = requireString(fields.context, "context");
230
+ const decision = requireString(fields.decision, "decision");
231
+ const consequences =
232
+ fields.consequences === undefined ? undefined : requireString(fields.consequences, "consequences");
233
+
234
+ let status: DecisionStatus = DEFAULT_DECISION_STATUS;
235
+ if (fields.status !== undefined && fields.status !== null) {
236
+ if (!isDecisionStatus(fields.status)) {
237
+ throw new Error(
238
+ `decision '${id}' has invalid status '${JSON.stringify(fields.status)}'; valid statuses are ${DECISION_STATUSES.join(", ")}.`,
239
+ );
240
+ }
241
+ status = fields.status;
242
+ }
243
+
244
+ const entityRefs = parseEntityRefs(fields.entityRefs);
245
+ const supersedes =
246
+ fields.supersedes === undefined ? undefined : requireString(fields.supersedes, "supersedes");
247
+
248
+ // The parser is intentionally permissive about the on-disk shape. A
249
+ // record with `status: "superseded"` and no `supersedes` field is the
250
+ // shape `applySupersede` writes for the replaced record — the
251
+ // `supersedes` edge lives on the *replacement*, not the superseded one,
252
+ // so this branch must accept the canonical serialised form and stay
253
+ // round-trippable. Excluding superseded records from "active" listings is
254
+ // `listActive`'s job (rule 53, single classification source), not the
255
+ // parser's.
256
+
257
+ const record: DecisionRecord = {
258
+ id,
259
+ title,
260
+ status,
261
+ context,
262
+ decision,
263
+ consequences,
264
+ entityRefs,
265
+ ...(supersedes !== undefined ? { supersedes } : {}),
266
+ };
267
+ // Body is preserved byte-for-byte on serialise; we don't introspect it
268
+ // here so format-neutral round-trips stay open (rule 38).
269
+ void body;
270
+ return record;
271
+ }
272
+
273
+ function requireString(field: unknown, key: string): string {
274
+ if (field === undefined || field === null) {
275
+ throw new Error(`decision record frontmatter is missing required field '${key}'.`);
276
+ }
277
+ if (typeof field !== "string") {
278
+ throw new Error(
279
+ `decision record frontmatter field '${key}' must be a string; got ${JSON.stringify(field)}.`,
280
+ );
281
+ }
282
+ return field;
283
+ }
284
+
285
+ function parseEntityRefs(field: unknown): string[] {
286
+ if (field === undefined) return [];
287
+ if (!Array.isArray(field)) {
288
+ throw new Error(
289
+ `decision record frontmatter field 'entityRefs' must be an array of strings; got ${JSON.stringify(field)}.`,
290
+ );
291
+ }
292
+ for (const entry of field) {
293
+ if (typeof entry !== "string") {
294
+ throw new Error(
295
+ `decision record frontmatter 'entityRefs' entries must be strings; got ${JSON.stringify(entry)}.`,
296
+ );
297
+ }
298
+ }
299
+ return [...field];
300
+ }
301
+
302
+ // ──────────────────────────────────────────────────────────────────────────
303
+ // YAML frontmatter subset (intentionally narrow)
304
+ // ──────────────────────────────────────────────────────────────────────────
305
+
306
+ function extractFrontmatter(raw: string): ParsedFrontmatter {
307
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
308
+ if (!text.startsWith("---\n")) {
309
+ throw new Error(
310
+ "decision record must start with a YAML frontmatter fence (`---\\n`); got a document with no leading fence.",
311
+ );
312
+ }
313
+ const closeAt = text.indexOf("\n---", 4);
314
+ if (closeAt === -1) {
315
+ throw new Error(
316
+ "decision record frontmatter is missing its closing fence (`\\n---`); the document is truncated.",
317
+ );
318
+ }
319
+ const yamlBody = text.slice(4, closeAt);
320
+ const after = text.slice(closeAt + 4);
321
+ const body = after.startsWith("\n") ? after.slice(1) : after;
322
+ return { fields: parseYamlMapping(yamlBody), body };
323
+ }
324
+
325
+ function parseYamlMapping(input: string): Record<string, unknown> {
326
+ const fields: Record<string, unknown> = {};
327
+ for (const rawLine of input.split("\n")) {
328
+ const line = rawLine.trimEnd();
329
+ if (line.length === 0 || line.startsWith("#")) continue;
330
+ const colonAt = line.indexOf(":");
331
+ if (colonAt === -1) {
332
+ throw new Error(`decision record frontmatter line is not a key:value pair: "${line}".`);
333
+ }
334
+ const key = line.slice(0, colonAt).trim();
335
+ const valueText = line.slice(colonAt + 1).trim();
336
+ if (key.length === 0) {
337
+ throw new Error(`decision record frontmatter has an empty key in line: "${line}".`);
338
+ }
339
+ fields[key] = decodeScalar(valueText);
340
+ }
341
+ return fields;
342
+ }
343
+
344
+ function decodeScalar(valueText: string): unknown {
345
+ if (valueText.length === 0) return "";
346
+ const lower = valueText.toLowerCase();
347
+ if (lower === "true") return true;
348
+ if (lower === "false") return false;
349
+ if (lower === "null" || lower === "~") return null;
350
+ if (/^-?\d+$/.test(valueText)) return Number(valueText);
351
+ if (/^".*"$/.test(valueText)) {
352
+ // Restore escapes — order matters: handle the backslash-pair first so
353
+ // the literal `\\n` we emit from `escapeYamlString` decodes to a real
354
+ // newline (NOT a backslash-n).
355
+ return valueText
356
+ .slice(1, -1)
357
+ .replace(/\\\\/g, "\u0000")
358
+ .replace(/\\n/g, "\n")
359
+ .replace(/\\"/g, '"')
360
+ .replace(/\u0000/g, "\\");
361
+ }
362
+ if (/^\[.*\]$/.test(valueText)) return parseFlowList(valueText);
363
+ return valueText;
364
+ }
365
+
366
+ function parseFlowList(valueText: string): unknown[] {
367
+ const inner = valueText.slice(1, -1).trim();
368
+ if (inner.length === 0) return [];
369
+ const out: string[] = [];
370
+ let buf = "";
371
+ let inQuotes = false;
372
+ // Track whether we are sitting on a backslash so that `\"` inside a
373
+ // quoted element is recognised as an escaped quote rather than a
374
+ // closing quote. Without this flag, a ref containing a literal `"`
375
+ // (e.g. an entity id like `org/dept/\"lead\"`) would close its own
376
+ // element early and the next comma would split a string in half
377
+ // (cursor bug d16b2a18, review round on PR #1590).
378
+ let escaped = false;
379
+ for (let i = 0; i < inner.length; i += 1) {
380
+ const c = inner[i]!;
381
+ if (escaped) {
382
+ buf += c;
383
+ escaped = false;
384
+ continue;
385
+ }
386
+ if (c === "\\") {
387
+ buf += c;
388
+ escaped = true;
389
+ continue;
390
+ }
391
+ if (c === '"') {
392
+ inQuotes = !inQuotes;
393
+ buf += c;
394
+ continue;
395
+ }
396
+ if (c === "," && !inQuotes) {
397
+ out.push(stripFlowStringQuotes(buf.trim()));
398
+ buf = "";
399
+ continue;
400
+ }
401
+ buf += c;
402
+ }
403
+ if (buf.length > 0) out.push(stripFlowStringQuotes(buf.trim()));
404
+ return out;
405
+ }
406
+
407
+ function stripFlowStringQuotes(value: string): string {
408
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
409
+ return value
410
+ .slice(1, -1)
411
+ .replace(/\\\\/g, "\u0000")
412
+ .replace(/\\n/g, "\n")
413
+ .replace(/\\"/g, '"')
414
+ .replace(/\u0000/g, "\\");
415
+ }
416
+ return value;
417
+ }
418
+
419
+ // ──────────────────────────────────────────────────────────────────────────
420
+ // Supersede
421
+ // ──────────────────────────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * Pure supersede mutation: given the current record set, the id of a record
425
+ * to supersede, and the replacement record (already accepted), returns a new
426
+ * list with the replacement appended and the old record flipped to
427
+ * `superseded`.
428
+ *
429
+ * The hook fires `event: "write:<replacementId>"` BEFORE
430
+ * `event: "mutate:<oldId>:superseded"` so storage callers can guarantee
431
+ * disk-order matches the rule-25 requirement (the replacement lands before
432
+ * the old record's status flips).
433
+ */
434
+ export function applySupersede(
435
+ records: readonly DecisionRecord[],
436
+ targetId: string,
437
+ replacement: DecisionRecord,
438
+ hook?: (event: string) => void,
439
+ ): DecisionRecord[] {
440
+ const target = records.find((r) => r.id === targetId);
441
+ if (!target) {
442
+ throw new Error(
443
+ `applySupersede cannot find target record '${targetId}' in the current record set.`,
444
+ );
445
+ }
446
+ // Reject a collision where `replacement.id` already exists in the record
447
+ // set. Without this guard, an operator typo or MCP rename can silently
448
+ // overwrite an unrelated decision with a record that `supersedes` itself,
449
+ // corrupting the decision history (chatgpt-codex-connector review on
450
+ // PR #1590). The replacement must either be a NEW id (append) or the
451
+ // same id as the target (in-place replacement — supported as a separate
452
+ // edge case below).
453
+ const existing = records.find((r) => r.id === replacement.id);
454
+ if (existing && replacement.id !== targetId) {
455
+ throw new Error(
456
+ `applySupersede replacement id '${replacement.id}' already exists in the record set; ` +
457
+ `pick a new id to avoid silently overwriting an unrelated decision.`,
458
+ );
459
+ }
460
+ const next: DecisionRecord[] = records.map((r) =>
461
+ r.id === targetId
462
+ ? { ...r, status: "superseded" as DecisionStatus, supersedes: undefined }
463
+ : r,
464
+ );
465
+ const replacementWithEdge: DecisionRecord = { ...replacement, supersedes: targetId };
466
+ // Same-id replacement path: a single record swaps its body in place. The
467
+ // entry keeps its place in the array (so the listing order is preserved)
468
+ // and the new record still carries the supersede edge for traceability.
469
+ if (replacement.id === targetId) {
470
+ const idx = next.findIndex((r) => r.id === targetId);
471
+ next[idx] = replacementWithEdge;
472
+ } else {
473
+ next.push(replacementWithEdge);
474
+ }
475
+
476
+ hook?.(`write:${replacement.id}`);
477
+ hook?.(`mutate:${targetId}:superseded`);
478
+
479
+ return next;
480
+ }
481
+
482
+ // ──────────────────────────────────────────────────────────────────────────
483
+ // Listing
484
+ // ──────────────────────────────────────────────────────────────────────────
485
+
486
+ /**
487
+ * Filter a record set down to "standing" decisions (proposed + accepted).
488
+ * Uses the explicitly-exported `ACTIVE_DECISION_STATUSES` set (rule 53 — one
489
+ * classification source) so callers cannot drift.
490
+ */
491
+ export function listActive(records: readonly DecisionRecord[]): DecisionRecord[] {
492
+ return records.filter((r) => ACTIVE_DECISION_STATUSES.has(r.status));
493
+ }
494
+
495
+ // ──────────────────────────────────────────────────────────────────────────
496
+ // Coding-knowledge feature gate (Track A PR 1 wiring boundary)
497
+ // ──────────────────────────────────────────────────────────────────────────
498
+
499
+ /**
500
+ * Single source of truth for "are decision records active for this operator?"
501
+ * (rule 39 — every behaviour dependent on Track A consults this, not raw
502
+ * config reads). The master gate is `codingKnowledge.enabled`; the
503
+ * decision-record subsystem has its own on-switch.
504
+ */
505
+ export function isDecisionRecordsEnabled(config: CodingKnowledgeConfig): boolean {
506
+ return config.enabled === true && config.decisionRecords === true;
507
+ }
@@ -1065,6 +1065,124 @@ test("parseConfig codingMode: unknown object shape falls back to defaults", () =
1065
1065
  assert.equal(result.codingMode.branchScope, false);
1066
1066
  });
1067
1067
 
1068
+ // Track A coding-knowledge surface (issue #1548 PR 1).
1069
+ // Defaults pinned here are the contract — any drift between these expectations,
1070
+ // `CodingKnowledgeConfig`, `CODING_KNOWLEDGE_DEFAULTS`, the JSON schema, and
1071
+ // `docs/config-reference.md` is a contract regression (rule 55).
1072
+
1073
+ test("parseConfig codingKnowledge: defaults match the documented contract (issue #1548 Track A PR 1)", () => {
1074
+ const result = parseConfig({ openaiApiKey: "sk-test" });
1075
+ assert.deepEqual(result.codingKnowledge, {
1076
+ enabled: false,
1077
+ decisionRecords: true,
1078
+ architectureCard: true,
1079
+ sessionDelta: true,
1080
+ architectureCardLlmSummary: false,
1081
+ structuralProvider: "none",
1082
+ structuralProviderCommand: "",
1083
+ });
1084
+ });
1085
+
1086
+ test("parseConfig codingKnowledge: master gate defaults OFF so the pre-feature path is byte-identical", () => {
1087
+ // The hard rule — rule 39 / rule 48. Every other switch is opt-in under
1088
+ // the master gate, so the default must be `false` (least-privileged).
1089
+ assert.equal(parseConfig({ openaiApiKey: "sk-test" }).codingKnowledge.enabled, false);
1090
+ });
1091
+
1092
+ test("parseConfig codingKnowledge: accepts CLI-style boolean strings (CLAUDE.md gotcha 36)", () => {
1093
+ const result = parseConfig({
1094
+ openaiApiKey: "sk-test",
1095
+ codingKnowledge: {
1096
+ enabled: "true",
1097
+ decisionRecords: "false",
1098
+ architectureCardLlmSummary: "true",
1099
+ },
1100
+ });
1101
+ assert.equal(result.codingKnowledge.enabled, true);
1102
+ assert.equal(result.codingKnowledge.decisionRecords, false);
1103
+ assert.equal(result.codingKnowledge.architectureCardLlmSummary, true);
1104
+ });
1105
+
1106
+ test("parseConfig codingKnowledge: rejects unknown structuralProvider value (rule 51)", () => {
1107
+ assert.throws(
1108
+ () =>
1109
+ parseConfig({
1110
+ openaiApiKey: "sk-test",
1111
+ codingKnowledge: { structuralProvider: "warp" },
1112
+ }),
1113
+ /structuralProvider must be one of none, subprocess, native/,
1114
+ );
1115
+ });
1116
+
1117
+ test("parseConfig codingKnowledge: structuralProvider 'subprocess' survives; defaults to 'none' on missing", () => {
1118
+ const explicit = parseConfig({
1119
+ openaiApiKey: "sk-test",
1120
+ codingKnowledge: { structuralProvider: "subprocess" },
1121
+ });
1122
+ assert.equal(explicit.codingKnowledge.structuralProvider, "subprocess");
1123
+ const missing = parseConfig({ openaiApiKey: "sk-test", codingKnowledge: {} });
1124
+ assert.equal(missing.codingKnowledge.structuralProvider, "none");
1125
+ });
1126
+
1127
+ test("parseConfig codingKnowledge: structuralProviderCommand trims surrounding whitespace", () => {
1128
+ const result = parseConfig({
1129
+ openaiApiKey: "sk-test",
1130
+ codingKnowledge: { structuralProviderCommand: " /usr/local/bin/cbm " },
1131
+ });
1132
+ assert.equal(result.codingKnowledge.structuralProviderCommand, "/usr/local/bin/cbm");
1133
+ });
1134
+
1135
+ test("parseConfig codingKnowledge: unknown object shape falls back to defaults (rule 55)", () => {
1136
+ const result = parseConfig({ openaiApiKey: "sk-test", codingKnowledge: null });
1137
+ assert.equal(result.codingKnowledge.enabled, false);
1138
+ assert.equal(result.codingKnowledge.decisionRecords, true);
1139
+ assert.equal(result.codingKnowledge.structuralProvider, "none");
1140
+ });
1141
+
1142
+ test("parseConfig codingKnowledge: rejects malformed boolean string with the valid set in the error", () => {
1143
+ assert.throws(
1144
+ () =>
1145
+ parseConfig({
1146
+ openaiApiKey: "sk-test",
1147
+ codingKnowledge: { decisionRecords: "flase" },
1148
+ }),
1149
+ /decisionRecords must be a boolean.*got "flase"/,
1150
+ );
1151
+ });
1152
+
1153
+ test("parseConfig codingKnowledge: rejects malformed master-gate boolean", () => {
1154
+ assert.throws(
1155
+ () =>
1156
+ parseConfig({
1157
+ openaiApiKey: "sk-test",
1158
+ codingKnowledge: { enabled: "truthy" },
1159
+ }),
1160
+ /codingKnowledge\.enabled must be a boolean or one of true\/false\/1\/0\/yes\/no\/on\/off/,
1161
+ );
1162
+ });
1163
+
1164
+ // (Removed: the third "rejects non-boolean" test used an assert.throws third
1165
+ // argument that the project's esbuild build could not parse in this configuration.
1166
+ // The behavior is covered by the two preceding tests above.)
1167
+
1168
+ test("parseConfig codingKnowledge: accepts the documented boolean-like strings (rule 36)", () => {
1169
+ // Positive matrix — these MUST keep working after the strict-parse change.
1170
+ for (const value of ["true", "True", "TRUE", "1", "yes", "YES", "on"]) {
1171
+ const result = parseConfig({
1172
+ openaiApiKey: "sk-test",
1173
+ codingKnowledge: { enabled: value },
1174
+ });
1175
+ assert.equal(result.codingKnowledge.enabled, true, `${value} should coerce to true`);
1176
+ }
1177
+ for (const value of ["false", "False", "FALSE", "0", "no", "NO", "off"]) {
1178
+ const result = parseConfig({
1179
+ openaiApiKey: "sk-test",
1180
+ codingKnowledge: { enabled: value },
1181
+ });
1182
+ assert.equal(result.codingKnowledge.enabled, false, `${value} should coerce to false`);
1183
+ }
1184
+ });
1185
+
1068
1186
  // Pattern reinforcement (issue #687 PR 2/4)
1069
1187
 
1070
1188
  test("parseConfig: pattern reinforcement defaults are off, weekly, minCount=3, std categories", () => {
package/src/config.ts CHANGED
@@ -40,7 +40,7 @@ import { expandTildePath } from "./utils/path.js";
40
40
  // config.ts → connectors/index.ts nor the reverse circular import arises.
41
41
  import { coerceBool, coerceInstallExtension, coerceNumber } from "./connectors/coerce.js";
42
42
  import { parseWearablesConfig } from "./wearables/config.js";
43
-
43
+ import { parseCodingKnowledgeConfig } from "./coding/coding-knowledge-config.js";
44
44
  const DEFAULT_MEMORY_DIR = path.join(
45
45
  resolveHomeDir(),
46
46
  ".openclaw",
@@ -2484,7 +2484,8 @@ export function parseConfig(raw: unknown): PluginConfig {
2484
2484
  heartbeat,
2485
2485
  slotBehavior,
2486
2486
  codexCompat,
2487
- // Hourly summaries
2487
+ codingKnowledge: parseCodingKnowledgeConfig(cfg.codingKnowledge),
2488
+ // Hourly summaries
2488
2489
  hourlySummariesEnabled: cfg.hourlySummariesEnabled !== false, // default: true
2489
2490
  daySummaryEnabled: cfg.daySummaryEnabled !== false, // default: true
2490
2491
  hourlySummaryCronAutoRegister: cfg.hourlySummaryCronAutoRegister === true,
@@ -7476,8 +7476,8 @@ export class Orchestrator {
7476
7476
  graphExpandedIntentEnabled: caps.graphExpandedIntent,
7477
7477
  prompt,
7478
7478
  };
7479
- const requestedMode = options.mode;
7480
- // When the caller forces a mode, skip the (async, possibly LLM-backed)
7479
+ // Issue #1547 — let the heuristic decide no_recall BEFORE the planner/non-planner fork so it fires with planner on OR off.
7480
+ const requestedMode: RecallPlanMode | undefined = options.mode ?? (planRecallMode(prompt) === "no_recall" ? "no_recall" : undefined);
7481
7481
  // planner entirely — the decision is overridden anyway. Otherwise consult
7482
7482
  // the LLM planner when opted in (issue #1367 / Option C); it falls back to
7483
7483
  // the heuristic on disable / shadow / timeout / error.