@remnic/core 9.3.670 → 9.3.672

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 (246) hide show
  1. package/dist/access-cli.js +22 -22
  2. package/dist/access-http.d.ts +4 -4
  3. package/dist/access-http.js +13 -13
  4. package/dist/access-mcp.d.ts +4 -4
  5. package/dist/access-mcp.js +12 -12
  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 +11 -11
  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-UOBLE67F.js → chunk-3IE22DJ2.js} +4 -4
  26. package/dist/{chunk-BQJUPECT.js → chunk-3OKWZT7F.js} +2 -2
  27. package/dist/{chunk-XS2CWEHZ.js → chunk-4QZ7H6FN.js} +2 -2
  28. package/dist/chunk-52LZ42LI.js +25 -0
  29. package/dist/chunk-52LZ42LI.js.map +1 -0
  30. package/dist/{chunk-2MXEVL75.js → chunk-6VP3YUCS.js} +2 -2
  31. package/dist/{chunk-UVUTV7CM.js → chunk-7O5CFNN4.js} +8 -8
  32. package/dist/{chunk-WXXLSZHA.js → chunk-ATRB6Q25.js} +2 -2
  33. package/dist/{chunk-46WUVFOD.js → chunk-AYGT6VBC.js} +4 -4
  34. package/dist/{chunk-KOXGLQS7.js → chunk-B6IUW76R.js} +2 -2
  35. package/dist/{chunk-AARDBQTA.js → chunk-CTCPB57O.js} +2 -2
  36. package/dist/{chunk-Q2LQZYQ7.js → chunk-CXKETYZ7.js} +3 -3
  37. package/dist/{chunk-23EBQ27U.js → chunk-EJYFPRED.js} +2 -2
  38. package/dist/{chunk-WKMCC4NQ.js → chunk-EUM7CZFM.js} +2 -2
  39. package/dist/{chunk-AZBV4RRY.js → chunk-FMSDA2D3.js} +1 -1
  40. package/dist/chunk-FMSDA2D3.js.map +1 -0
  41. package/dist/{chunk-QHWJG5C5.js → chunk-FP4ISXI3.js} +5 -5
  42. package/dist/{chunk-SXYCVRLK.js → chunk-GSTYVG5L.js} +3 -3
  43. package/dist/{chunk-CRO4LCQ6.js → chunk-KJOYHNS7.js} +5 -5
  44. package/dist/{chunk-XMWF6AU3.js → chunk-LVTTO3VC.js} +2 -2
  45. package/dist/{chunk-TJ7HH5LB.js → chunk-LZSMQHXC.js} +2 -2
  46. package/dist/{chunk-PIA4C3AJ.js → chunk-M3WF2AB6.js} +14 -14
  47. package/dist/{chunk-CTAV55JM.js → chunk-MLVMBV2C.js} +75 -1
  48. package/dist/chunk-MLVMBV2C.js.map +1 -0
  49. package/dist/{chunk-QZ7ODIVL.js → chunk-MNUPGYIV.js} +2 -2
  50. package/dist/{chunk-MR4PJ277.js → chunk-MTJ2LFAJ.js} +2 -2
  51. package/dist/{chunk-2TCHDANJ.js → chunk-NXBXM7Q6.js} +2 -2
  52. package/dist/{chunk-TIJYQXDI.js → chunk-OHX52AOS.js} +2 -2
  53. package/dist/{chunk-PEPHBH2W.js → chunk-PYTATYUV.js} +2 -2
  54. package/dist/{chunk-JTPXSXHC.js → chunk-V4ZHKCGA.js} +2 -2
  55. package/dist/{chunk-OI4BXFSB.js → chunk-VL5JJOOY.js} +2 -2
  56. package/dist/{chunk-A5TEHAR4.js → chunk-ZCVPFDHB.js} +3 -3
  57. package/dist/{chunk-MPXYHC35.js → chunk-ZQJHKN7J.js} +20 -20
  58. package/dist/{chunk-4T7P2HLJ.js → chunk-ZUPFMHJA.js} +3 -3
  59. package/dist/{cli-C98xlwYA.d.ts → cli-B2Ve7R22.d.ts} +3 -3
  60. package/dist/cli.d.ts +5 -5
  61. package/dist/cli.js +26 -26
  62. package/dist/coding/optional-coding-graph.d.ts +63 -0
  63. package/dist/coding/optional-coding-graph.js +119 -0
  64. package/dist/coding/optional-coding-graph.js.map +1 -0
  65. package/dist/coding-graph-types-Dd2tGrnm.d.ts +106 -0
  66. package/dist/compounding/engine.d.ts +1 -1
  67. package/dist/compounding/engine.js +3 -3
  68. package/dist/compounding/preference-consolidator.d.ts +1 -1
  69. package/dist/compression-optimizer.d.ts +1 -1
  70. package/dist/config.d.ts +1 -1
  71. package/dist/config.js +1 -1
  72. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  73. package/dist/connectors/codex-materialize-runner.js +3 -3
  74. package/dist/connectors/codex-materialize.d.ts +1 -1
  75. package/dist/connectors/index.d.ts +1 -1
  76. package/dist/connectors/index.js +3 -3
  77. package/dist/consolidation-provenance-check.d.ts +1 -1
  78. package/dist/consolidation-undo.d.ts +1 -1
  79. package/dist/contradiction/index.d.ts +1 -1
  80. package/dist/conversation-index/backend.d.ts +1 -1
  81. package/dist/conversation-index/backend.js +2 -2
  82. package/dist/conversation-index/chunker.d.ts +1 -1
  83. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  84. package/dist/conversation-index/indexer.d.ts +1 -1
  85. package/dist/conversation-index/search.d.ts +1 -1
  86. package/dist/day-summary.d.ts +1 -1
  87. package/dist/delinearize.d.ts +1 -1
  88. package/dist/direct-answer-wiring.d.ts +1 -1
  89. package/dist/direct-answer.d.ts +1 -1
  90. package/dist/embedding-fallback.d.ts +1 -1
  91. package/dist/enrichment/index.d.ts +1 -1
  92. package/dist/entity-retrieval.d.ts +1 -1
  93. package/dist/entity-retrieval.js +3 -3
  94. package/dist/entity-schema.d.ts +1 -1
  95. package/dist/explicit-capture.d.ts +3 -3
  96. package/dist/extraction-judge-telemetry.d.ts +1 -1
  97. package/dist/extraction-judge-training.d.ts +1 -1
  98. package/dist/extraction-judge.d.ts +1 -1
  99. package/dist/extraction.d.ts +1 -1
  100. package/dist/fallback-llm.d.ts +1 -1
  101. package/dist/identity-continuity.d.ts +1 -1
  102. package/dist/importance.d.ts +1 -1
  103. package/dist/index.d.ts +9 -8
  104. package/dist/index.js +39 -33
  105. package/dist/index.js.map +1 -1
  106. package/dist/intent.d.ts +1 -1
  107. package/dist/lcm/engine.d.ts +1 -1
  108. package/dist/lcm/engine.js +2 -2
  109. package/dist/lcm/index.d.ts +1 -1
  110. package/dist/lcm/index.js +2 -2
  111. package/dist/lcm/tools.d.ts +1 -1
  112. package/dist/lifecycle.d.ts +1 -1
  113. package/dist/live-connectors-runner.d.ts +1 -1
  114. package/dist/local-llm.d.ts +1 -1
  115. package/dist/maintenance/memory-governance.d.ts +1 -1
  116. package/dist/maintenance/memory-governance.js +3 -3
  117. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  118. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  119. package/dist/mcp-memory-inspector-app.d.ts +4 -4
  120. package/dist/memory-action-policy.d.ts +1 -1
  121. package/dist/memory-cache.d.ts +1 -1
  122. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  123. package/dist/memory-projection-store.d.ts +1 -1
  124. package/dist/memory-provenance.d.ts +1 -1
  125. package/dist/memory-worth-outcomes.d.ts +1 -1
  126. package/dist/models-json.d.ts +1 -1
  127. package/dist/namespaces/migrate.d.ts +1 -1
  128. package/dist/namespaces/migrate.js +8 -8
  129. package/dist/namespaces/principal.d.ts +1 -1
  130. package/dist/namespaces/search.d.ts +1 -1
  131. package/dist/namespaces/search.js +4 -4
  132. package/dist/namespaces/storage.d.ts +1 -1
  133. package/dist/namespaces/storage.js +3 -3
  134. package/dist/native-knowledge.d.ts +1 -1
  135. package/dist/operator-toolkit.d.ts +1 -1
  136. package/dist/operator-toolkit.js +11 -11
  137. package/dist/{orchestrator-DyP9QYsh.d.ts → orchestrator-7zPqGupX.d.ts} +2 -2
  138. package/dist/orchestrator.d.ts +3 -3
  139. package/dist/orchestrator.js +18 -18
  140. package/dist/patterns-cli.d.ts +1 -1
  141. package/dist/policy-runtime.d.ts +1 -1
  142. package/dist/qmd-recall-cache.d.ts +1 -1
  143. package/dist/qmd.d.ts +1 -1
  144. package/dist/recall-disclosure-escalation.d.ts +1 -1
  145. package/dist/recall-explain-renderer.d.ts +1 -1
  146. package/dist/recall-explain-renderer.js +3 -3
  147. package/dist/recall-planner-llm.d.ts +1 -1
  148. package/dist/recall-state.d.ts +1 -1
  149. package/dist/recall-tag-filter.d.ts +1 -1
  150. package/dist/recall-xray-cli.d.ts +1 -1
  151. package/dist/recall-xray-cli.js +4 -4
  152. package/dist/recall-xray-renderer.d.ts +1 -1
  153. package/dist/recall-xray-renderer.js +3 -3
  154. package/dist/recall-xray.d.ts +1 -1
  155. package/dist/recall-xray.js +2 -2
  156. package/dist/resolve-auth-token.d.ts +1 -1
  157. package/dist/resume-bundles.js +2 -2
  158. package/dist/retrieval-agents.d.ts +1 -1
  159. package/dist/retrieval-tiers.d.ts +1 -1
  160. package/dist/routing/engine.d.ts +1 -1
  161. package/dist/routing/store.d.ts +1 -1
  162. package/dist/schemas.d.ts +22 -22
  163. package/dist/search/embed-helper.d.ts +1 -1
  164. package/dist/search/factory.d.ts +1 -1
  165. package/dist/search/factory.js +3 -3
  166. package/dist/search/index.d.ts +1 -1
  167. package/dist/search/index.js +3 -3
  168. package/dist/search/lancedb-backend.d.ts +1 -1
  169. package/dist/search/meilisearch-backend.d.ts +1 -1
  170. package/dist/search/noop-backend.d.ts +1 -1
  171. package/dist/search/orama-backend.d.ts +1 -1
  172. package/dist/search/port.d.ts +1 -1
  173. package/dist/search/remote-backend.d.ts +1 -1
  174. package/dist/{semantic-consolidation-BICZvQ3C.d.ts → semantic-consolidation-BX9Z9_aK.d.ts} +1 -1
  175. package/dist/semantic-consolidation.d.ts +2 -2
  176. package/dist/semantic-consolidation.js +4 -4
  177. package/dist/semantic-rule-promotion.js +3 -3
  178. package/dist/semantic-rule-verifier.d.ts +1 -1
  179. package/dist/semantic-rule-verifier.js +3 -3
  180. package/dist/session-observer-bands.d.ts +1 -1
  181. package/dist/session-observer-state.d.ts +1 -1
  182. package/dist/shared-context/manager.d.ts +1 -1
  183. package/dist/signal.d.ts +1 -1
  184. package/dist/storage.d.ts +1 -1
  185. package/dist/storage.js +2 -2
  186. package/dist/summarizer.d.ts +1 -1
  187. package/dist/summary-snapshot.d.ts +1 -1
  188. package/dist/temporal-supersession.d.ts +1 -1
  189. package/dist/temporal-validity.d.ts +1 -1
  190. package/dist/threading.d.ts +1 -1
  191. package/dist/tier-migration.d.ts +1 -1
  192. package/dist/tier-routing.d.ts +1 -1
  193. package/dist/topics.d.ts +1 -1
  194. package/dist/transcript.d.ts +1 -1
  195. package/dist/transfer/types.d.ts +12 -12
  196. package/dist/{types-D96bCB3C.d.ts → types-D3pm4NhH.d.ts} +30 -1
  197. package/dist/types.d.ts +1 -1
  198. package/dist/types.js +1 -1
  199. package/dist/utility-runtime.d.ts +1 -1
  200. package/dist/verified-recall.js +3 -3
  201. package/package.json +19 -1
  202. package/src/coding/coding-graph-types.ts +180 -0
  203. package/src/coding/coding-knowledge-config.ts +111 -0
  204. package/src/coding/decision-records.test.ts +434 -0
  205. package/src/coding/decision-records.ts +507 -0
  206. package/src/coding/optional-coding-graph-cache.test.ts +86 -0
  207. package/src/coding/optional-coding-graph-cacheread.test.ts +78 -0
  208. package/src/coding/optional-coding-graph-concurrent.test.ts +48 -0
  209. package/src/coding/optional-coding-graph-incompatible.test.ts +98 -0
  210. package/src/coding/optional-coding-graph-probe.test.ts +34 -0
  211. package/src/coding/optional-coding-graph.test.ts +117 -0
  212. package/src/coding/optional-coding-graph.ts +370 -0
  213. package/src/config.test.ts +118 -0
  214. package/src/config.ts +3 -2
  215. package/src/index.ts +22 -0
  216. package/src/types.ts +35 -0
  217. package/dist/chunk-AZBV4RRY.js.map +0 -1
  218. package/dist/chunk-CTAV55JM.js.map +0 -1
  219. /package/dist/{chunk-UOBLE67F.js.map → chunk-3IE22DJ2.js.map} +0 -0
  220. /package/dist/{chunk-BQJUPECT.js.map → chunk-3OKWZT7F.js.map} +0 -0
  221. /package/dist/{chunk-XS2CWEHZ.js.map → chunk-4QZ7H6FN.js.map} +0 -0
  222. /package/dist/{chunk-2MXEVL75.js.map → chunk-6VP3YUCS.js.map} +0 -0
  223. /package/dist/{chunk-UVUTV7CM.js.map → chunk-7O5CFNN4.js.map} +0 -0
  224. /package/dist/{chunk-WXXLSZHA.js.map → chunk-ATRB6Q25.js.map} +0 -0
  225. /package/dist/{chunk-46WUVFOD.js.map → chunk-AYGT6VBC.js.map} +0 -0
  226. /package/dist/{chunk-KOXGLQS7.js.map → chunk-B6IUW76R.js.map} +0 -0
  227. /package/dist/{chunk-AARDBQTA.js.map → chunk-CTCPB57O.js.map} +0 -0
  228. /package/dist/{chunk-Q2LQZYQ7.js.map → chunk-CXKETYZ7.js.map} +0 -0
  229. /package/dist/{chunk-23EBQ27U.js.map → chunk-EJYFPRED.js.map} +0 -0
  230. /package/dist/{chunk-WKMCC4NQ.js.map → chunk-EUM7CZFM.js.map} +0 -0
  231. /package/dist/{chunk-QHWJG5C5.js.map → chunk-FP4ISXI3.js.map} +0 -0
  232. /package/dist/{chunk-SXYCVRLK.js.map → chunk-GSTYVG5L.js.map} +0 -0
  233. /package/dist/{chunk-CRO4LCQ6.js.map → chunk-KJOYHNS7.js.map} +0 -0
  234. /package/dist/{chunk-XMWF6AU3.js.map → chunk-LVTTO3VC.js.map} +0 -0
  235. /package/dist/{chunk-TJ7HH5LB.js.map → chunk-LZSMQHXC.js.map} +0 -0
  236. /package/dist/{chunk-PIA4C3AJ.js.map → chunk-M3WF2AB6.js.map} +0 -0
  237. /package/dist/{chunk-QZ7ODIVL.js.map → chunk-MNUPGYIV.js.map} +0 -0
  238. /package/dist/{chunk-MR4PJ277.js.map → chunk-MTJ2LFAJ.js.map} +0 -0
  239. /package/dist/{chunk-2TCHDANJ.js.map → chunk-NXBXM7Q6.js.map} +0 -0
  240. /package/dist/{chunk-TIJYQXDI.js.map → chunk-OHX52AOS.js.map} +0 -0
  241. /package/dist/{chunk-PEPHBH2W.js.map → chunk-PYTATYUV.js.map} +0 -0
  242. /package/dist/{chunk-JTPXSXHC.js.map → chunk-V4ZHKCGA.js.map} +0 -0
  243. /package/dist/{chunk-OI4BXFSB.js.map → chunk-VL5JJOOY.js.map} +0 -0
  244. /package/dist/{chunk-A5TEHAR4.js.map → chunk-ZCVPFDHB.js.map} +0 -0
  245. /package/dist/{chunk-MPXYHC35.js.map → chunk-ZQJHKN7J.js.map} +0 -0
  246. /package/dist/{chunk-4T7P2HLJ.js.map → chunk-ZUPFMHJA.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
+ }
@@ -0,0 +1,86 @@
1
+ // Regression tests for Cursor Bugbot P2 round 3 on PR #1588:
2
+ // "Probe poisons loader error path" and the round-5 "Loader skips
3
+ // success cache" follow-up.
4
+ //
5
+ // These tests must work both when @remnic/coding-graph is installed
6
+ // (workspace dev scenario) AND when it is absent (CI base install —
7
+ // the optional peer is not symlinked). Conditional branches keep the
8
+ // tests durable across install scenarios (P2 P2 P2 chatgpt-codex on
9
+ // PR #1588 round 6).
10
+
11
+ import assert from "node:assert/strict";
12
+ import test from "node:test";
13
+
14
+ import {
15
+ isCodingGraphInstalled,
16
+ loadCodingGraphEngineFactory,
17
+ tryLoadCodingGraphModule,
18
+ } from "./optional-coding-graph.js";
19
+
20
+ test("loadCodingGraphEngineFactory fresh-attempts regardless of probe state", async () => {
21
+ // Probe and load side-by-side. Both must agree on the package's
22
+ // presence at this instant. When @remnic/coding-graph is absent
23
+ // (CI base install) the probe returns null and the loader throws
24
+ // the install hint. When present (dev workspace) both succeed.
25
+ const probeResult = await tryLoadCodingGraphModule();
26
+ const installed = await isCodingGraphInstalled();
27
+
28
+ if (probeResult === null) {
29
+ assert.equal(installed, false);
30
+ let threw = false;
31
+ let message = "";
32
+ try {
33
+ await loadCodingGraphEngineFactory();
34
+ } catch (err) {
35
+ threw = true;
36
+ message = err instanceof Error ? err.message : String(err);
37
+ }
38
+ assert.equal(threw, true, "loader must throw install hint when probe returned null");
39
+ assert.match(message, /@remnic\/coding-graph/);
40
+ assert.match(message, /npm install @remnic\/coding-graph/);
41
+ return;
42
+ }
43
+
44
+ // Package present.
45
+ assert.equal(installed, true);
46
+ const factory = await loadCodingGraphEngineFactory();
47
+ assert.equal(typeof factory, "function");
48
+ });
49
+
50
+ test("probe short-circuits to the same answer on repeated calls", async () => {
51
+ // Probe must be deterministic — repeated calls return the same
52
+ // answer. Both branches are valid (present → module, absent → null).
53
+ const a = await tryLoadCodingGraphModule();
54
+ const b = await tryLoadCodingGraphModule();
55
+ const aKind = a === null ? "null" : "module";
56
+ const bKind = b === null ? "null" : "module";
57
+ assert.equal(aKind, bKind, "consecutive probe calls must agree");
58
+ });
59
+
60
+ test("loader path is independent of the probe result", async () => {
61
+ // The Bugbot P2 round 3 bug was: a probe that caught a non-specifier
62
+ // import error was caching `null` into the load-path. That poisoned
63
+ // the user-facing loader path. This test asserts the load-path
64
+ // ALWAYS attempts a fresh import — proven by exercising the path
65
+ // for both present and absent packages.
66
+ const probe = await tryLoadCodingGraphModule();
67
+ if (probe === null) {
68
+ // Absent: loader throws install hint.
69
+ let threw = false;
70
+ try {
71
+ await loadCodingGraphEngineFactory();
72
+ } catch {
73
+ threw = true;
74
+ }
75
+ assert.equal(threw, true, "loader throws when package is absent");
76
+ } else {
77
+ // Present: loader returns a callable factory.
78
+ const factory = await loadCodingGraphEngineFactory();
79
+ assert.equal(typeof factory, "function");
80
+ assert.throws(() => factory(), (err: unknown) => {
81
+ if (!err || typeof err !== "object") return false;
82
+ const e = err as { name?: unknown; code?: unknown };
83
+ return e.name === "CodingGraphError" && e.code === "not_implemented";
84
+ });
85
+ }
86
+ });
@@ -0,0 +1,78 @@
1
+ // Regression tests for Cursor Bugbot P3 round 5 on PR #1588:
2
+ // "Loader skips success cache".
3
+ //
4
+ // These tests must work both when @remnic/coding-graph is installed
5
+ // (workspace dev scenario) AND when it is absent (CI base install —
6
+ // the optional peer is not symlinked). chatgpt-codex round 6 P2:
7
+ // the original test assumed the package is installed and would fail
8
+ // in a core-only install. Every assertion below is conditional.
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+
13
+ import {
14
+ isCodingGraphInstalled,
15
+ loadCodingGraphEngineFactory,
16
+ tryLoadCodingGraphModule,
17
+ } from "./optional-coding-graph.js";
18
+
19
+ test("loadCodingGraphEngineFactory returns a callable factory (install present)", async () => {
20
+ const installed = await isCodingGraphInstalled();
21
+ if (!installed) {
22
+ // Skip when package is absent (CI base install).
23
+ return;
24
+ }
25
+ const factory = await loadCodingGraphEngineFactory();
26
+ assert.equal(typeof factory, "function");
27
+ });
28
+
29
+ test("loadCodingGraphEngineFactory throws install hint when package absent", async () => {
30
+ const installed = await isCodingGraphInstalled();
31
+ if (installed) {
32
+ return;
33
+ }
34
+ let threw = false;
35
+ let message = "";
36
+ try {
37
+ await loadCodingGraphEngineFactory();
38
+ } catch (err) {
39
+ threw = true;
40
+ message = err instanceof Error ? err.message : String(err);
41
+ }
42
+ assert.equal(threw, true);
43
+ assert.match(message, /@remnic\/coding-graph/);
44
+ assert.match(message, /npm install @remnic\/coding-graph/);
45
+ });
46
+
47
+ test("after a successful load, tryLoadCodingGraphModule returns the same module (cache fast-path)", async () => {
48
+ const installed = await isCodingGraphInstalled();
49
+ if (!installed) return;
50
+ // Seed the success cache by calling loadCodingGraphEngineFactory.
51
+ const factory = await loadCodingGraphEngineFactory();
52
+ assert.equal(typeof factory, "function");
53
+ // A fresh probe call should return the cached module reference.
54
+ const probeResult = await tryLoadCodingGraphModule();
55
+ assert.ok(probeResult !== null);
56
+ // Invoking the factory throws the placeholder (PR1 contract).
57
+ assert.throws(() => factory(), (err: unknown) => {
58
+ if (!err || typeof err !== "object") return false;
59
+ const e = err as { name?: unknown; code?: unknown };
60
+ return e.name === "CodingGraphError" && e.code === "not_implemented";
61
+ });
62
+ });
63
+
64
+ test("loader fresh-attempts after the package is detected as missing", async () => {
65
+ // If present, loader returns a factory.
66
+ // If absent, loader throws the install hint.
67
+ try {
68
+ const factory = await loadCodingGraphEngineFactory();
69
+ assert.equal(typeof factory, "function");
70
+ } catch (err) {
71
+ if (err instanceof Error) {
72
+ assert.match(err.message, /@remnic\/coding-graph/);
73
+ assert.match(err.message, /npm install @remnic\/coding-graph/);
74
+ return;
75
+ }
76
+ throw err;
77
+ }
78
+ });