@decibelsystems/tools 2.2.0-beta.0 → 3.0.0

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 (273) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/agentPresence.d.ts +80 -0
  3. package/dist/agentPresence.d.ts.map +1 -1
  4. package/dist/agentPresence.js +177 -0
  5. package/dist/agentPresence.js.map +1 -1
  6. package/dist/agentic/index.d.ts +18 -8
  7. package/dist/agentic/index.d.ts.map +1 -1
  8. package/dist/agentic/index.js +18 -144
  9. package/dist/agentic/index.js.map +1 -1
  10. package/dist/agentic/types.d.ts +106 -0
  11. package/dist/agentic/types.d.ts.map +1 -1
  12. package/dist/agentic/types.js +77 -1
  13. package/dist/agentic/types.js.map +1 -1
  14. package/dist/architectAdrs.d.ts +14 -2
  15. package/dist/architectAdrs.d.ts.map +1 -1
  16. package/dist/architectAdrs.js +9 -3
  17. package/dist/architectAdrs.js.map +1 -1
  18. package/dist/client/transports.d.ts.map +1 -1
  19. package/dist/client/transports.js +13 -3
  20. package/dist/client/transports.js.map +1 -1
  21. package/dist/daemon.d.ts +19 -0
  22. package/dist/daemon.d.ts.map +1 -1
  23. package/dist/daemon.js +181 -38
  24. package/dist/daemon.js.map +1 -1
  25. package/dist/daemonConfig.d.ts +24 -0
  26. package/dist/daemonConfig.d.ts.map +1 -1
  27. package/dist/daemonConfig.js +46 -12
  28. package/dist/daemonConfig.js.map +1 -1
  29. package/dist/decibelPaths.d.ts.map +1 -1
  30. package/dist/decibelPaths.js +39 -6
  31. package/dist/decibelPaths.js.map +1 -1
  32. package/dist/domain/issue.d.ts +77 -0
  33. package/dist/domain/issue.d.ts.map +1 -0
  34. package/dist/domain/issue.js +134 -0
  35. package/dist/domain/issue.js.map +1 -0
  36. package/dist/domain/issueCodec.d.ts +39 -0
  37. package/dist/domain/issueCodec.d.ts.map +1 -0
  38. package/dist/domain/issueCodec.js +298 -0
  39. package/dist/domain/issueCodec.js.map +1 -0
  40. package/dist/domain/issueRepository.d.ts +91 -0
  41. package/dist/domain/issueRepository.d.ts.map +1 -0
  42. package/dist/domain/issueRepository.js +257 -0
  43. package/dist/domain/issueRepository.js.map +1 -0
  44. package/dist/facades/definitions.d.ts +0 -1
  45. package/dist/facades/definitions.d.ts.map +1 -1
  46. package/dist/facades/definitions.js +94 -113
  47. package/dist/facades/definitions.js.map +1 -1
  48. package/dist/facades/index.d.ts +2 -1
  49. package/dist/facades/index.d.ts.map +1 -1
  50. package/dist/facades/index.js +1 -1
  51. package/dist/facades/index.js.map +1 -1
  52. package/dist/facades/types.d.ts +17 -0
  53. package/dist/facades/types.d.ts.map +1 -1
  54. package/dist/httpArgs.d.ts +11 -0
  55. package/dist/httpArgs.d.ts.map +1 -0
  56. package/dist/httpArgs.js +39 -0
  57. package/dist/httpArgs.js.map +1 -0
  58. package/dist/httpServer.d.ts +1 -12
  59. package/dist/httpServer.d.ts.map +1 -1
  60. package/dist/httpServer.js +97 -96
  61. package/dist/httpServer.js.map +1 -1
  62. package/dist/kernel.d.ts +46 -1
  63. package/dist/kernel.d.ts.map +1 -1
  64. package/dist/kernel.js +311 -26
  65. package/dist/kernel.js.map +1 -1
  66. package/dist/lib/agent-services/assumptions.d.ts.map +1 -1
  67. package/dist/lib/agent-services/assumptions.js +8 -2
  68. package/dist/lib/agent-services/assumptions.js.map +1 -1
  69. package/dist/lib/atomicWrite.d.ts +26 -0
  70. package/dist/lib/atomicWrite.d.ts.map +1 -0
  71. package/dist/lib/atomicWrite.js +73 -0
  72. package/dist/lib/atomicWrite.js.map +1 -0
  73. package/dist/lib/envelope.d.ts +31 -0
  74. package/dist/lib/envelope.d.ts.map +1 -0
  75. package/dist/lib/envelope.js +80 -0
  76. package/dist/lib/envelope.js.map +1 -0
  77. package/dist/lib/fileLock.d.ts +38 -0
  78. package/dist/lib/fileLock.d.ts.map +1 -0
  79. package/dist/lib/fileLock.js +124 -0
  80. package/dist/lib/fileLock.js.map +1 -0
  81. package/dist/lib/recordIdAllocator.d.ts +86 -0
  82. package/dist/lib/recordIdAllocator.d.ts.map +1 -0
  83. package/dist/lib/recordIdAllocator.js +161 -0
  84. package/dist/lib/recordIdAllocator.js.map +1 -0
  85. package/dist/projectRegistry.d.ts.map +1 -1
  86. package/dist/projectRegistry.js +11 -0
  87. package/dist/projectRegistry.js.map +1 -1
  88. package/dist/runtime/circuitBreaker.d.ts +95 -0
  89. package/dist/runtime/circuitBreaker.d.ts.map +1 -0
  90. package/dist/runtime/circuitBreaker.js +178 -0
  91. package/dist/runtime/circuitBreaker.js.map +1 -0
  92. package/dist/runtime/ensureRuntime.d.ts +103 -0
  93. package/dist/runtime/ensureRuntime.d.ts.map +1 -0
  94. package/dist/runtime/ensureRuntime.js +281 -0
  95. package/dist/runtime/ensureRuntime.js.map +1 -0
  96. package/dist/runtime/extensions.d.ts +57 -0
  97. package/dist/runtime/extensions.d.ts.map +1 -0
  98. package/dist/runtime/extensions.js +216 -0
  99. package/dist/runtime/extensions.js.map +1 -0
  100. package/dist/runtime/localHttp.d.ts +24 -0
  101. package/dist/runtime/localHttp.d.ts.map +1 -0
  102. package/dist/runtime/localHttp.js +84 -0
  103. package/dist/runtime/localHttp.js.map +1 -0
  104. package/dist/runtime/projectResolution.d.ts +29 -0
  105. package/dist/runtime/projectResolution.d.ts.map +1 -0
  106. package/dist/runtime/projectResolution.js +67 -0
  107. package/dist/runtime/projectResolution.js.map +1 -0
  108. package/dist/runtime/protocol.d.ts +62 -0
  109. package/dist/runtime/protocol.d.ts.map +1 -0
  110. package/dist/runtime/protocol.js +74 -0
  111. package/dist/runtime/protocol.js.map +1 -0
  112. package/dist/sentinelIssues.d.ts +26 -2
  113. package/dist/sentinelIssues.d.ts.map +1 -1
  114. package/dist/sentinelIssues.js +125 -64
  115. package/dist/sentinelIssues.js.map +1 -1
  116. package/dist/server.js +59 -12
  117. package/dist/server.js.map +1 -1
  118. package/dist/tools/agentic/index.d.ts.map +1 -1
  119. package/dist/tools/agentic/index.js +28 -2
  120. package/dist/tools/agentic/index.js.map +1 -1
  121. package/dist/tools/architect/index.d.ts.map +1 -1
  122. package/dist/tools/architect/index.js +7 -2
  123. package/dist/tools/architect/index.js.map +1 -1
  124. package/dist/tools/context.d.ts +10 -5
  125. package/dist/tools/context.d.ts.map +1 -1
  126. package/dist/tools/context.js +45 -18
  127. package/dist/tools/context.js.map +1 -1
  128. package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
  129. package/dist/tools/coordinator/coordinator.js +48 -19
  130. package/dist/tools/coordinator/coordinator.js.map +1 -1
  131. package/dist/tools/crit.d.ts +2 -1
  132. package/dist/tools/crit.d.ts.map +1 -1
  133. package/dist/tools/crit.js +19 -4
  134. package/dist/tools/crit.js.map +1 -1
  135. package/dist/tools/designer/index.d.ts.map +1 -1
  136. package/dist/tools/designer/index.js +4 -3
  137. package/dist/tools/designer/index.js.map +1 -1
  138. package/dist/tools/designer/lateral-tools.d.ts.map +1 -1
  139. package/dist/tools/designer/lateral-tools.js +4 -2
  140. package/dist/tools/designer/lateral-tools.js.map +1 -1
  141. package/dist/tools/designer.d.ts +2 -1
  142. package/dist/tools/designer.d.ts.map +1 -1
  143. package/dist/tools/designer.js +24 -12
  144. package/dist/tools/designer.js.map +1 -1
  145. package/dist/tools/dojo.d.ts +3 -2
  146. package/dist/tools/dojo.d.ts.map +1 -1
  147. package/dist/tools/dojo.js +64 -22
  148. package/dist/tools/dojo.js.map +1 -1
  149. package/dist/tools/friction.d.ts.map +1 -1
  150. package/dist/tools/friction.js +16 -6
  151. package/dist/tools/friction.js.map +1 -1
  152. package/dist/tools/guardian.d.ts.map +1 -1
  153. package/dist/tools/guardian.js +6 -2
  154. package/dist/tools/guardian.js.map +1 -1
  155. package/dist/tools/hygiene/oracle-hygiene.d.ts.map +1 -1
  156. package/dist/tools/hygiene/oracle-hygiene.js +8 -2
  157. package/dist/tools/hygiene/oracle-hygiene.js.map +1 -1
  158. package/dist/tools/index.d.ts.map +1 -1
  159. package/dist/tools/index.js +27 -18
  160. package/dist/tools/index.js.map +1 -1
  161. package/dist/tools/learnings.d.ts +2 -1
  162. package/dist/tools/learnings.d.ts.map +1 -1
  163. package/dist/tools/learnings.js +31 -15
  164. package/dist/tools/learnings.js.map +1 -1
  165. package/dist/tools/oracle.d.ts.map +1 -1
  166. package/dist/tools/oracle.js +19 -6
  167. package/dist/tools/oracle.js.map +1 -1
  168. package/dist/tools/policy.d.ts +2 -1
  169. package/dist/tools/policy.d.ts.map +1 -1
  170. package/dist/tools/policy.js +28 -9
  171. package/dist/tools/policy.js.map +1 -1
  172. package/dist/tools/postoffice/index.d.ts +10 -0
  173. package/dist/tools/postoffice/index.d.ts.map +1 -0
  174. package/dist/tools/postoffice/index.js +218 -0
  175. package/dist/tools/postoffice/index.js.map +1 -0
  176. package/dist/tools/postoffice.d.ts +78 -0
  177. package/dist/tools/postoffice.d.ts.map +1 -0
  178. package/dist/tools/postoffice.js +199 -0
  179. package/dist/tools/postoffice.js.map +1 -0
  180. package/dist/tools/provenance.d.ts +7 -0
  181. package/dist/tools/provenance.d.ts.map +1 -1
  182. package/dist/tools/provenance.js +42 -16
  183. package/dist/tools/provenance.js.map +1 -1
  184. package/dist/tools/sentinel/index.d.ts +1 -0
  185. package/dist/tools/sentinel/index.d.ts.map +1 -1
  186. package/dist/tools/sentinel/index.js +88 -4
  187. package/dist/tools/sentinel/index.js.map +1 -1
  188. package/dist/tools/sentinel.d.ts +102 -1
  189. package/dist/tools/sentinel.d.ts.map +1 -1
  190. package/dist/tools/sentinel.js +544 -328
  191. package/dist/tools/sentinel.js.map +1 -1
  192. package/dist/tools/shared/index.d.ts +2 -1
  193. package/dist/tools/shared/index.d.ts.map +1 -1
  194. package/dist/tools/shared/index.js +2 -1
  195. package/dist/tools/shared/index.js.map +1 -1
  196. package/dist/tools/shared/response.d.ts +24 -0
  197. package/dist/tools/shared/response.d.ts.map +1 -1
  198. package/dist/tools/shared/response.js +61 -7
  199. package/dist/tools/shared/response.js.map +1 -1
  200. package/dist/tools/shared/storeRead.d.ts +153 -0
  201. package/dist/tools/shared/storeRead.d.ts.map +1 -0
  202. package/dist/tools/shared/storeRead.js +242 -0
  203. package/dist/tools/shared/storeRead.js.map +1 -0
  204. package/dist/tools/testSpec.d.ts +3 -2
  205. package/dist/tools/testSpec.d.ts.map +1 -1
  206. package/dist/tools/testSpec.js +51 -14
  207. package/dist/tools/testSpec.js.map +1 -1
  208. package/dist/tools/vector.d.ts +33 -1
  209. package/dist/tools/vector.d.ts.map +1 -1
  210. package/dist/tools/vector.js +31 -5
  211. package/dist/tools/vector.js.map +1 -1
  212. package/dist/tools/zoom/index.d.ts +15 -0
  213. package/dist/tools/zoom/index.d.ts.map +1 -0
  214. package/dist/tools/zoom/index.js +174 -0
  215. package/dist/tools/zoom/index.js.map +1 -0
  216. package/dist/tools/zoom.d.ts +234 -0
  217. package/dist/tools/zoom.d.ts.map +1 -0
  218. package/dist/tools/zoom.js +627 -0
  219. package/dist/tools/zoom.js.map +1 -0
  220. package/dist/transports/bridge.d.ts +1 -1
  221. package/dist/transports/bridge.d.ts.map +1 -1
  222. package/dist/transports/bridge.js +2 -0
  223. package/dist/transports/bridge.js.map +1 -1
  224. package/dist/transports/http.d.ts +1 -1
  225. package/dist/transports/http.d.ts.map +1 -1
  226. package/dist/transports/http.js +3 -1
  227. package/dist/transports/http.js.map +1 -1
  228. package/dist/transports/index.d.ts +1 -0
  229. package/dist/transports/index.d.ts.map +1 -1
  230. package/dist/transports/index.js +1 -0
  231. package/dist/transports/index.js.map +1 -1
  232. package/dist/transports/mcp.d.ts +1 -1
  233. package/dist/transports/mcp.d.ts.map +1 -1
  234. package/dist/transports/mcp.js +7 -3
  235. package/dist/transports/mcp.js.map +1 -1
  236. package/dist/transports/stdio.d.ts +1 -1
  237. package/dist/transports/stdio.d.ts.map +1 -1
  238. package/dist/transports/stdio.js +2 -0
  239. package/dist/transports/stdio.js.map +1 -1
  240. package/dist/transports/thinStdio.d.ts +22 -0
  241. package/dist/transports/thinStdio.d.ts.map +1 -0
  242. package/dist/transports/thinStdio.js +218 -0
  243. package/dist/transports/thinStdio.js.map +1 -0
  244. package/dist/transports/types.d.ts +8 -1
  245. package/dist/transports/types.d.ts.map +1 -1
  246. package/dist/types/agent-services.d.ts +7 -0
  247. package/dist/types/agent-services.d.ts.map +1 -1
  248. package/package.json +9 -4
  249. package/templates/com.decibel.daemon.plist +6 -1
  250. package/dist/tools/deck.d.ts +0 -11
  251. package/dist/tools/deck.d.ts.map +0 -1
  252. package/dist/tools/deck.js +0 -1656
  253. package/dist/tools/deck.js.map +0 -1
  254. package/dist/tools/mother.d.ts +0 -11
  255. package/dist/tools/mother.d.ts.map +0 -1
  256. package/dist/tools/mother.js +0 -503
  257. package/dist/tools/mother.js.map +0 -1
  258. package/dist/tools/registry.d.ts +0 -3
  259. package/dist/tools/registry.d.ts.map +0 -1
  260. package/dist/tools/registry.js +0 -189
  261. package/dist/tools/registry.js.map +0 -1
  262. package/dist/tools/senken.d.ts +0 -11
  263. package/dist/tools/senken.d.ts.map +0 -1
  264. package/dist/tools/senken.js +0 -482
  265. package/dist/tools/senken.js.map +0 -1
  266. package/dist/tools/terminal.d.ts +0 -17
  267. package/dist/tools/terminal.d.ts.map +0 -1
  268. package/dist/tools/terminal.js +0 -968
  269. package/dist/tools/terminal.js.map +0 -1
  270. package/dist/tools/toolsIndex.d.ts +0 -5
  271. package/dist/tools/toolsIndex.d.ts.map +0 -1
  272. package/dist/tools/toolsIndex.js +0 -37
  273. package/dist/tools/toolsIndex.js.map +0 -1
@@ -0,0 +1,298 @@
1
+ // ============================================================================
2
+ // Issue codec — EPIC-0038 Phase 3
3
+ // ============================================================================
4
+ // Translates between on-disk records and the canonical Issue model. This is the
5
+ // only place that knows there are two formats.
6
+ //
7
+ // Two rules shape the whole file:
8
+ //
9
+ // 1. DECODE IS LOSSLESS. Every frontmatter key survives a decode/encode round
10
+ // trip, whether or not the model has a field for it. Records in this store
11
+ // carry keys no interface declares — `repo`, `closed_reason`, `degraded`,
12
+ // `source`, `projectId` alongside `project` — and a codec that emitted only
13
+ // the fields it recognized would silently delete them on the first write.
14
+ // The precedent is real: update_issue once rewrote markdown records as bare
15
+ // YAML and dropped the delimiters, the heading and the body.
16
+ //
17
+ // 2. THE BODY MIRROR IS OUTPUT, NEVER INPUT. Markdown records repeat status and
18
+ // severity in the body as `**Status:** x` for human readers. Decode ignores
19
+ // those lines entirely and reads frontmatter; encode regenerates them from
20
+ // the model. That asymmetry is the point — it makes the drift that hit
21
+ // sixteen records structurally impossible rather than merely fixed. There is
22
+ // no code path that can write a mirror disagreeing with its frontmatter.
23
+ // ============================================================================
24
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
25
+ import { isUid, normalizeStatus, } from './issue.js';
26
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
27
+ /** Keys the model owns. Everything else is preserved verbatim in raw.data. */
28
+ const MODEL_KEYS = new Set([
29
+ 'uid',
30
+ 'id',
31
+ 'title',
32
+ 'status',
33
+ 'severity',
34
+ 'priority',
35
+ 'tags',
36
+ 'epic_id',
37
+ 'epicId',
38
+ 'project',
39
+ 'description',
40
+ 'details',
41
+ 'resolution',
42
+ 'created_at',
43
+ 'updated_at',
44
+ 'closed_at',
45
+ ]);
46
+ function normalizeTags(v) {
47
+ if (!Array.isArray(v))
48
+ return undefined;
49
+ const tags = v.filter((t) => typeof t === 'string' && t.trim() !== '');
50
+ return tags.length > 0 ? tags : undefined;
51
+ }
52
+ function str(v) {
53
+ return typeof v === 'string' && v.trim() !== '' ? v : undefined;
54
+ }
55
+ /**
56
+ * Split a bare-YAML record into its YAML head and any markdown tail at column 0.
57
+ *
58
+ * The column-0 anchor is load-bearing and must not gain `\s*`: headings indented
59
+ * inside a `description:` block scalar are body text, not a tail, and matching
60
+ * them would truncate the record at parse time. Phase 2 verified no record in
61
+ * the store relies on the difference, but nothing else pins it, so it is pinned
62
+ * by test here.
63
+ */
64
+ export function splitBareYaml(content) {
65
+ const lines = content.split('\n');
66
+ const idx = lines.findIndex((l) => /^#{1,6}\s/.test(l));
67
+ if (idx < 0)
68
+ return { head: content, tail: '' };
69
+ return { head: lines.slice(0, idx).join('\n'), tail: lines.slice(idx).join('\n') };
70
+ }
71
+ /**
72
+ * Prose under `## Details`, which is where the markdown format keeps the body.
73
+ *
74
+ * This used to stop at the next `##` heading of any kind, which quietly made
75
+ * the format unable to hold a structured issue. Authors write `## Problem`,
76
+ * `## Root Cause`, `## Fix` — 44 records in this store did, and every one read
77
+ * back truncated at the first of them, in two cases from 107 characters to 30.
78
+ *
79
+ * Only ONE trailing section is not the author's: `## Resolution`, which
80
+ * encodeIssue regenerates from `issue.resolution` and which decode reads from
81
+ * frontmatter. Including it here would duplicate it into details and then
82
+ * re-append it on every write. Everything else belongs to the body.
83
+ *
84
+ * The trailing occurrence is the one stripped, not the first — an issue whose
85
+ * prose discusses a resolution mid-body keeps it.
86
+ */
87
+ function extractDetails(body) {
88
+ const m = body.match(/^##\s+Details\s*\n([\s\S]*)$/m);
89
+ if (!m)
90
+ return undefined;
91
+ let rest = m[1];
92
+ const headings = [...rest.matchAll(/\n##\s+Resolution\s*(?:\n|$)/g)];
93
+ const last = headings[headings.length - 1];
94
+ if (last?.index !== undefined)
95
+ rest = rest.slice(0, last.index);
96
+ return rest.trim() || undefined;
97
+ }
98
+ export function decodeIssue(filename, content) {
99
+ const warnings = [];
100
+ const fm = FRONTMATTER_RE.exec(content);
101
+ let data;
102
+ let body;
103
+ let format;
104
+ if (fm) {
105
+ format = 'md';
106
+ body = fm[2];
107
+ try {
108
+ data = parseYaml(fm[1]) ?? {};
109
+ }
110
+ catch (err) {
111
+ data = {};
112
+ warnings.push(`frontmatter did not parse: ${err instanceof Error ? err.message : String(err)}`);
113
+ }
114
+ }
115
+ else {
116
+ format = 'yaml';
117
+ const split = splitBareYaml(content);
118
+ body = split.tail;
119
+ if (split.tail.trim()) {
120
+ // Phase 2 repaired every instance of this. A new one means a writer is
121
+ // appending markdown to bare YAML again.
122
+ warnings.push('bare-YAML record has a markdown tail — a writer is appending prose outside the YAML');
123
+ }
124
+ try {
125
+ data = parseYaml(split.head) ?? {};
126
+ }
127
+ catch (err) {
128
+ data = {};
129
+ warnings.push(`YAML did not parse: ${err instanceof Error ? err.message : String(err)}`);
130
+ }
131
+ }
132
+ if (typeof data !== 'object' || data === null) {
133
+ data = {};
134
+ warnings.push('record did not parse to an object');
135
+ }
136
+ // ---- id: frontmatter ISS-NNNN, else the filename ------------------------
137
+ const fmId = str(data.id);
138
+ const id = (fmId && /^ISS-\d+$/i.test(fmId) ? fmId.toUpperCase() : undefined) ??
139
+ filename.match(/^(ISS-\d+)/i)?.[1]?.toUpperCase() ??
140
+ fmId ??
141
+ filename.replace(/\.(md|ya?ml)$/i, '');
142
+ // ---- title: frontmatter wins, then the heading, then the filename -------
143
+ //
144
+ // Frontmatter first, because the writer puts a title there ONLY when the
145
+ // body cannot hold it — no heading at all, or a title containing a newline,
146
+ // which a `# heading` truncates at the line break. Reading the heading first
147
+ // meant a multi-line title came back as its first line, from a record whose
148
+ // frontmatter held the whole thing: "first line\nsecond line" read back as
149
+ // "first line", with create_issue reporting success.
150
+ //
151
+ // Preferring frontmatter cannot regress a record that predates this, because
152
+ // the writer never emitted both: `title` went to frontmatter exactly when
153
+ // there was no heading to compete with it.
154
+ const heading = body.match(/^#\s+(.+)$/m)?.[1]?.trim();
155
+ const fmTitle = str(data.title);
156
+ const title = fmTitle ?? heading ?? filename.replace(/\.(md|ya?ml)$/i, '');
157
+ if (!heading && !fmTitle) {
158
+ warnings.push('no title in heading or frontmatter — fell back to the filename');
159
+ }
160
+ // ---- status: the whole reason this codec exists -------------------------
161
+ const norm = normalizeStatus(data.status);
162
+ if (norm.unrecognized) {
163
+ warnings.push(`unrecognized status ${JSON.stringify(data.status)} — read as ${norm.status}`);
164
+ }
165
+ else if (norm.normalizedFrom) {
166
+ warnings.push(`status ${JSON.stringify(norm.normalizedFrom)} normalized to ${norm.status}`);
167
+ }
168
+ const uid = isUid(data.uid) ? data.uid : undefined;
169
+ if (data.uid !== undefined && !uid) {
170
+ warnings.push(`uid ${JSON.stringify(data.uid)} is not a valid UUIDv7 — ignored`);
171
+ }
172
+ const issue = {
173
+ uid,
174
+ id,
175
+ title,
176
+ status: norm.status,
177
+ severity: str(data.severity),
178
+ priority: str(data.priority),
179
+ // An empty tag list and no tag list mean the same thing, and encode omits
180
+ // empty arrays — so decoding [] to [] would make the codec non-idempotent.
181
+ // Twelve records in the store carry `tags: []`; the corpus test caught it.
182
+ tags: normalizeTags(data.tags),
183
+ epicId: str(data.epic_id) ?? str(data.epicId),
184
+ // Both spellings exist; `projectId` is the markdown writer's, `project` the
185
+ // YAML writer's. The model has one, and encode puts it back where it came from.
186
+ project: str(data.project) ?? str(data.projectId),
187
+ details: str(data.description) ?? extractDetails(body),
188
+ resolution: str(data.resolution),
189
+ created_at: str(data.created_at),
190
+ updated_at: str(data.updated_at),
191
+ closed_at: str(data.closed_at),
192
+ };
193
+ // Strip model-owned keys; whatever remains is carried through untouched.
194
+ const preserved = {};
195
+ for (const [k, v] of Object.entries(data)) {
196
+ if (!MODEL_KEYS.has(k) && k !== 'projectId')
197
+ preserved[k] = v;
198
+ }
199
+ return { issue, format, raw: { data: preserved, body }, warnings };
200
+ }
201
+ /**
202
+ * Rewrite the derived header lines in a markdown body from the model.
203
+ *
204
+ * Only lines that already exist are updated — this does not inject a mirror
205
+ * into a record that never had one, because the mirror is a convention of the
206
+ * markdown writer's layout, not a requirement of the format.
207
+ */
208
+ function syncBodyMirror(body, issue) {
209
+ let out = body;
210
+ out = out.replace(/^\*\*Status:\*\* .*$/m, `**Status:** ${issue.status}`);
211
+ if (issue.severity) {
212
+ out = out.replace(/^\*\*Severity:\*\* .*$/m, `**Severity:** ${issue.severity}`);
213
+ }
214
+ if (issue.epicId) {
215
+ out = out.replace(/^\*\*Epic:\*\* .*$/m, `**Epic:** ${issue.epicId}`);
216
+ }
217
+ // The heading is the title's home in this format; keep them in step.
218
+ //
219
+ // Only ever the FIRST LINE. Splicing a multi-line title into the heading
220
+ // line pushed its remaining lines into the body as loose prose — and since
221
+ // the writer had already put them there once, they appeared twice. The
222
+ // authoritative copy of such a title is in frontmatter (see encodeIssue);
223
+ // this is the human-readable rendering of it.
224
+ out = out.replace(/^#\s+.+$/m, `# ${issue.title.split(/\r?\n/)[0]}`);
225
+ // Resolution is rendered into the body for the same reason status is: a
226
+ // markdown record is read by people, and `## Resolution` is where they look.
227
+ // It is regenerated from the model rather than stored there, so it cannot
228
+ // drift from frontmatter the way the status mirror did. Writing it ONLY to
229
+ // frontmatter would have been the tidier data model and the worse record —
230
+ // the prose would have silently vanished from every closed issue.
231
+ if (issue.resolution) {
232
+ const section = `## Resolution\n\n${issue.resolution}\n`;
233
+ out = /^##\s+Resolution\s*$/m.test(out)
234
+ ? out.replace(/^##\s+Resolution\s*\n[\s\S]*?(?=\n##\s|$)/m, section)
235
+ : `${out.replace(/\s+$/, '')}\n\n${section}`;
236
+ }
237
+ return out;
238
+ }
239
+ export function encodeIssue(decoded) {
240
+ const { issue, format, raw } = decoded;
241
+ const data = {};
242
+ const put = (k, v) => {
243
+ if (v !== undefined && v !== null && !(Array.isArray(v) && v.length === 0))
244
+ data[k] = v;
245
+ };
246
+ // Field order is stable so diffs stay readable across writes.
247
+ put('uid', issue.uid);
248
+ put('id', issue.id);
249
+ // Markdown records normally carry the title in the `# heading`, and writing
250
+ // it to frontmatter as well would create a second copy to drift — the same
251
+ // mistake as the status mirror. But a record with no heading has nowhere else
252
+ // to keep it, and omitting it there means the title is silently replaced by
253
+ // the filename on the next read. So: frontmatter holds the title exactly when
254
+ // the body cannot.
255
+ //
256
+ // A MULTI-LINE TITLE IS A TITLE THE BODY CANNOT HOLD. A markdown heading ends
257
+ // at the newline, so `# first\nsecond` is a heading reading "first" followed
258
+ // by a stray paragraph, and the record came back a line long with no error
259
+ // anywhere in the chain (ISS-0155). YAML quotes and escapes it exactly, so
260
+ // frontmatter is the only container that round-trips one.
261
+ const bodyHasHeading = /^#\s+.+$/m.test(raw.body);
262
+ const titleFitsAHeading = !/[\r\n]/.test(issue.title);
263
+ if (format === 'yaml' || !bodyHasHeading || !titleFitsAHeading)
264
+ put('title', issue.title);
265
+ // The markdown writer spells it projectId; keep each format's own spelling so
266
+ // a rewrite is not a gratuitous diff on every record.
267
+ put(format === 'md' ? 'projectId' : 'project', issue.project);
268
+ put('severity', issue.severity);
269
+ put('status', issue.status);
270
+ put('priority', issue.priority);
271
+ put('epic_id', issue.epicId);
272
+ put('tags', issue.tags);
273
+ put('created_at', issue.created_at);
274
+ put('updated_at', issue.updated_at);
275
+ put('closed_at', issue.closed_at);
276
+ // Bare YAML keeps prose in `description`; markdown keeps it in the body.
277
+ if (format === 'yaml')
278
+ put('description', issue.details);
279
+ put('resolution', issue.resolution);
280
+ // Preserved unknown keys land after the model's, so they never displace a
281
+ // known field's position but are never lost either.
282
+ for (const [k, v] of Object.entries(raw.data))
283
+ if (!(k in data))
284
+ data[k] = v;
285
+ const yaml = stringifyYaml(data, { lineWidth: 0 });
286
+ if (format === 'md') {
287
+ const body = syncBodyMirror(raw.body, issue);
288
+ return `---\n${yaml}---\n${body}`;
289
+ }
290
+ // A bare-YAML tail should not exist post-Phase-2, but if one is present it is
291
+ // pre-existing prose and gets preserved rather than dropped.
292
+ return raw.body.trim() ? `${yaml}\n${raw.body}` : yaml;
293
+ }
294
+ /** Apply changes to a decoded record, keeping derived state consistent. */
295
+ export function applyIssueChanges(decoded, changes) {
296
+ return { ...decoded, issue: { ...decoded.issue, ...changes } };
297
+ }
298
+ //# sourceMappingURL=issueCodec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issueCodec.js","sourceRoot":"","sources":["../../src/domain/issueCodec.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ import { Issue, IssuePriority, IssueSeverity, IssueStatus } from './issue.js';
2
+ import { IssueFormat } from './issueCodec.js';
3
+ /** An issue plus where it lives. The path is for diagnostics, not for callers to write through. */
4
+ export interface StoredIssue {
5
+ issue: Issue;
6
+ filename: string;
7
+ path: string;
8
+ format: IssueFormat;
9
+ /** Integrity observations from decoding. Empty for a healthy record. */
10
+ warnings: string[];
11
+ }
12
+ export interface IssueListFilter {
13
+ status?: IssueStatus;
14
+ /** All non-terminal statuses. Distinct from status:'open', which is one value. */
15
+ liveOnly?: boolean;
16
+ epicId?: string;
17
+ }
18
+ export interface CreateIssueSpec {
19
+ title: string;
20
+ details: string;
21
+ severity: IssueSeverity;
22
+ priority?: IssuePriority;
23
+ epicId?: string;
24
+ tags?: string[];
25
+ project?: string;
26
+ }
27
+ /**
28
+ * Integrity state of the whole store, reported alongside a listing rather than
29
+ * discovered mid-incident.
30
+ *
31
+ * The distinction that matters: `malformed` records are invisible to callers,
32
+ * so a silent skip would make a broken store look like a shorter clean one.
33
+ * `degraded` records are readable but carry something the codec had to
34
+ * interpret. Both are repair queues, not cosmetics — a duplicate id makes an
35
+ * issue unwritable, because the repository refuses an ambiguous target.
36
+ */
37
+ export interface StoreIntegrity {
38
+ malformed: string[];
39
+ degraded: Array<{
40
+ filename: string;
41
+ warnings: string[];
42
+ }>;
43
+ duplicateIds: Record<string, string[]>;
44
+ }
45
+ export interface IssueRepository {
46
+ integrity(): Promise<StoreIntegrity>;
47
+ list(filter?: IssueListFilter): Promise<StoredIssue[]>;
48
+ get(idOrFilename: string): Promise<StoredIssue | null>;
49
+ create(spec: CreateIssueSpec): Promise<StoredIssue>;
50
+ update(idOrFilename: string, changes: Partial<Issue>): Promise<StoredIssue>;
51
+ close(idOrFilename: string, resolution: string, status?: 'closed' | 'wontfix'): Promise<StoredIssue>;
52
+ }
53
+ export declare class AmbiguousIssueIdError extends Error {
54
+ readonly issueId: string;
55
+ readonly candidates: string[];
56
+ readonly code = "AMBIGUOUS_ISSUE_ID";
57
+ constructor(issueId: string, candidates: string[]);
58
+ }
59
+ export declare class IssueNotFoundError extends Error {
60
+ readonly issueId: string;
61
+ readonly code = "ISSUE_NOT_FOUND";
62
+ constructor(issueId: string);
63
+ }
64
+ export declare class FsIssueRepository implements IssueRepository {
65
+ private readonly issuesDir;
66
+ constructor(issuesDir: string);
67
+ private readAll;
68
+ /**
69
+ * Duplicate detection deliberately runs over EVERY record, never a filtered
70
+ * subset: a closed twin still makes an id ambiguous for writers, so filtering
71
+ * it out of a listing must not filter it out of the collision report.
72
+ */
73
+ integrity(): Promise<StoreIntegrity>;
74
+ list(filter?: IssueListFilter): Promise<StoredIssue[]>;
75
+ /**
76
+ * Resolve an id to exactly one record, in tiers of decreasing specificity.
77
+ *
78
+ * Only the first non-empty tier is considered, and ambiguity is reported
79
+ * within a tier rather than across tiers — so naming a record by its filename
80
+ * stays a reliable way to pick one member of a duplicate pair even while the
81
+ * shared ISS-NNNN remains ambiguous. uid is checked first because it is the
82
+ * only identifier guaranteed unique by construction.
83
+ */
84
+ private resolve;
85
+ get(idOrFilename: string): Promise<StoredIssue | null>;
86
+ private requireOne;
87
+ create(spec: CreateIssueSpec): Promise<StoredIssue>;
88
+ update(idOrFilename: string, changes: Partial<Issue>): Promise<StoredIssue>;
89
+ close(idOrFilename: string, resolution: string, status?: 'closed' | 'wontfix'): Promise<StoredIssue>;
90
+ }
91
+ //# sourceMappingURL=issueRepository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issueRepository.d.ts","sourceRoot":"","sources":["../../src/domain/issueRepository.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,257 @@
1
+ // ============================================================================
2
+ // Issue repository — EPIC-0038 Phase 3
3
+ // ============================================================================
4
+ // The seam between "what an issue is" and "where issues live".
5
+ //
6
+ // Callers ask for issues by id and get back canonical Issue objects. They do
7
+ // not learn whether a record was markdown-with-frontmatter or bare YAML, which
8
+ // directory it came from, or how its id was resolved. That is the whole point:
9
+ // Phase 5 converges the on-disk format and should be a change to the codec and
10
+ // this file only, and a future SupabaseIssueRepository should be a sibling
11
+ // rather than a rewrite of every caller.
12
+ //
13
+ // Resolution is the subtle part. An id can name more than one record — four
14
+ // duplicate groups existed before Phase 2 repaired them — and the historical
15
+ // failure mode was `files.find(...)` picking whichever the directory listing
16
+ // happened to yield first, so writers had an undefined target and reported
17
+ // success either way. Ambiguity is therefore an error here, never a guess.
18
+ // ============================================================================
19
+ import { promises as fs } from 'fs';
20
+ import path from 'path';
21
+ import { writeFileAtomic } from '../lib/atomicWrite.js';
22
+ import { allocateAndWriteIssue } from '../lib/recordIdAllocator.js';
23
+ import { listDirOrThrow } from '../tools/shared/storeRead.js';
24
+ import { isLive, newUid, } from './issue.js';
25
+ import { decodeIssue, encodeIssue, } from './issueCodec.js';
26
+ export class AmbiguousIssueIdError extends Error {
27
+ issueId;
28
+ candidates;
29
+ code = 'AMBIGUOUS_ISSUE_ID';
30
+ constructor(issueId, candidates) {
31
+ super(`Issue id ${issueId} matches ${candidates.length} records: ${candidates.join(', ')}. ` +
32
+ `Name one by filename, or by its uid, to disambiguate.`);
33
+ this.issueId = issueId;
34
+ this.candidates = candidates;
35
+ this.name = 'AmbiguousIssueIdError';
36
+ }
37
+ }
38
+ export class IssueNotFoundError extends Error {
39
+ issueId;
40
+ code = 'ISSUE_NOT_FOUND';
41
+ constructor(issueId) {
42
+ super(`Issue ${issueId} not found.`);
43
+ this.issueId = issueId;
44
+ this.name = 'IssueNotFoundError';
45
+ }
46
+ }
47
+ const RECORD_RE = /\.(md|ya?ml)$/i;
48
+ function slugify(text) {
49
+ return text
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, '-')
52
+ .replace(/^-+|-+$/g, '')
53
+ .slice(0, 50);
54
+ }
55
+ export class FsIssueRepository {
56
+ issuesDir;
57
+ constructor(issuesDir) {
58
+ this.issuesDir = issuesDir;
59
+ }
60
+ async readAll() {
61
+ // ISS-0153. ENOENT is the only error that means "empty": a directory that
62
+ // was never created is what an untouched project looks like. EACCES,
63
+ // EPERM, EIO and the rest mean the issues may well be there and this
64
+ // process cannot see them — returning [] there reported "no issues" for a
65
+ // store it could not open, and list_issues is the read most likely to be
66
+ // believed.
67
+ const names = await listDirOrThrow(this.issuesDir, (f) => RECORD_RE.test(f));
68
+ const out = [];
69
+ for (const filename of names) {
70
+ const full = path.join(this.issuesDir, filename);
71
+ let content;
72
+ try {
73
+ content = await fs.readFile(full, 'utf-8');
74
+ }
75
+ catch {
76
+ continue; // Unreadable record: skip rather than fail the whole listing.
77
+ }
78
+ const decoded = decodeIssue(filename, content);
79
+ out.push({
80
+ issue: decoded.issue,
81
+ filename,
82
+ path: full,
83
+ format: decoded.format,
84
+ warnings: decoded.warnings,
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+ /**
90
+ * Duplicate detection deliberately runs over EVERY record, never a filtered
91
+ * subset: a closed twin still makes an id ambiguous for writers, so filtering
92
+ * it out of a listing must not filter it out of the collision report.
93
+ */
94
+ async integrity() {
95
+ const all = await this.readAll();
96
+ const malformed = [];
97
+ const degraded = [];
98
+ const claims = new Map();
99
+ for (const r of all) {
100
+ const unparseable = r.warnings.some((w) => w.includes('did not parse'));
101
+ if (unparseable)
102
+ malformed.push(r.filename);
103
+ else if (r.warnings.length > 0)
104
+ degraded.push({ filename: r.filename, warnings: r.warnings });
105
+ const key = r.issue.id.toUpperCase();
106
+ claims.set(key, [...(claims.get(key) ?? []), r.filename]);
107
+ }
108
+ const duplicateIds = {};
109
+ for (const [id, files] of claims)
110
+ if (files.length > 1)
111
+ duplicateIds[id] = files;
112
+ return { malformed, degraded, duplicateIds };
113
+ }
114
+ async list(filter = {}) {
115
+ // A record that did not parse is not an issue — returning it would hand
116
+ // callers a husk built from filename fallbacks. It is still counted by
117
+ // integrity(), so the file is reported rather than silently dropped.
118
+ let all = (await this.readAll()).filter((r) => !r.warnings.some((w) => w.includes('did not parse')));
119
+ if (filter.status)
120
+ all = all.filter((r) => r.issue.status === filter.status);
121
+ if (filter.liveOnly)
122
+ all = all.filter((r) => isLive(r.issue.status));
123
+ if (filter.epicId)
124
+ all = all.filter((r) => r.issue.epicId === filter.epicId);
125
+ return all;
126
+ }
127
+ /**
128
+ * Resolve an id to exactly one record, in tiers of decreasing specificity.
129
+ *
130
+ * Only the first non-empty tier is considered, and ambiguity is reported
131
+ * within a tier rather than across tiers — so naming a record by its filename
132
+ * stays a reliable way to pick one member of a duplicate pair even while the
133
+ * shared ISS-NNNN remains ambiguous. uid is checked first because it is the
134
+ * only identifier guaranteed unique by construction.
135
+ */
136
+ async resolve(idOrFilename) {
137
+ const all = await this.readAll();
138
+ const needle = idOrFilename.trim();
139
+ const upper = needle.toUpperCase();
140
+ const byUid = all.filter((r) => r.issue.uid && r.issue.uid === needle);
141
+ if (byUid.length > 0)
142
+ return byUid;
143
+ const byFilename = all.filter((r) => r.filename.toUpperCase() === upper);
144
+ if (byFilename.length > 0)
145
+ return byFilename;
146
+ // Filename without the extension, which is how list output used to name records.
147
+ const byStem = all.filter((r) => r.filename.replace(RECORD_RE, '').toUpperCase() === upper.replace(RECORD_RE, ''));
148
+ if (byStem.length > 0)
149
+ return byStem;
150
+ // ISS-NNNN, requiring the id to end at a separator or the extension. A bare
151
+ // startsWith made "ISS-011" swallow ISS-0110, ISS-0112 and ISS-0119.
152
+ const byId = all.filter((r) => {
153
+ if (r.issue.id.toUpperCase() === upper)
154
+ return true;
155
+ const base = r.filename.toUpperCase();
156
+ if (!base.startsWith(upper))
157
+ return false;
158
+ const rest = base.slice(upper.length);
159
+ return rest === '' || rest.startsWith('-') || /^\.(MD|YA?ML)$/.test(rest);
160
+ });
161
+ if (byId.length > 0)
162
+ return byId;
163
+ // Last tier: a partial id like "ISS-011", which is a prefix of ISS-0110,
164
+ // ISS-0112 and ISS-0119. The boundary rule above deliberately refuses to
165
+ // resolve it, but refusing is not the same as saying nothing is there —
166
+ // "not found" would be a misleading answer when three records obviously
167
+ // start with it. So a loose prefix is reported as a collision when it spans
168
+ // several records, and is NEVER resolved to a single one: silently picking
169
+ // one is the swallow bug this whole tier system exists to prevent.
170
+ const loose = all.filter((r) => r.filename.toUpperCase().startsWith(upper));
171
+ return loose.length > 1 ? loose : [];
172
+ }
173
+ async get(idOrFilename) {
174
+ const matches = await this.resolve(idOrFilename);
175
+ if (matches.length === 0)
176
+ return null;
177
+ if (matches.length > 1) {
178
+ throw new AmbiguousIssueIdError(idOrFilename, matches.map((m) => m.filename));
179
+ }
180
+ return matches[0];
181
+ }
182
+ async requireOne(idOrFilename) {
183
+ const found = await this.get(idOrFilename);
184
+ if (!found)
185
+ throw new IssueNotFoundError(idOrFilename);
186
+ return found;
187
+ }
188
+ async create(spec) {
189
+ const now = new Date().toISOString();
190
+ const slug = slugify(spec.title);
191
+ // Allocation and write happen under one cross-process lock (Phase 1). Every
192
+ // new record gets a uid at birth, so the backfill only ever has to deal
193
+ // with records that predate this path.
194
+ const issue = {
195
+ uid: newUid(),
196
+ id: 'ISS-PENDING',
197
+ title: spec.title,
198
+ status: 'open',
199
+ severity: spec.severity,
200
+ priority: spec.priority,
201
+ tags: spec.tags,
202
+ epicId: spec.epicId,
203
+ project: spec.project,
204
+ details: spec.details,
205
+ created_at: now,
206
+ };
207
+ const build = (issueId) => {
208
+ const body = [
209
+ // First line only — a heading ends at the newline, so the rest would
210
+ // land in the body as stray prose. encodeIssue writes the whole title
211
+ // to frontmatter when it does not fit here.
212
+ `# ${spec.title.split(/\r?\n/)[0]}`,
213
+ '',
214
+ `**Severity:** ${spec.severity}`,
215
+ `**Status:** open`,
216
+ ...(spec.epicId ? [`**Epic:** ${spec.epicId}`] : []),
217
+ '',
218
+ '## Details',
219
+ '',
220
+ spec.details,
221
+ '',
222
+ ].join('\n');
223
+ const decoded = {
224
+ issue: { ...issue, id: issueId },
225
+ format: 'md',
226
+ raw: { data: {}, body },
227
+ warnings: [],
228
+ };
229
+ return encodeIssue(decoded);
230
+ };
231
+ const allocated = await allocateAndWriteIssue(this.issuesDir, 'md', slug, build);
232
+ return this.requireOne(allocated.filename);
233
+ }
234
+ async update(idOrFilename, changes) {
235
+ const current = await this.requireOne(idOrFilename);
236
+ const content = await fs.readFile(current.path, 'utf-8');
237
+ const decoded = decodeIssue(current.filename, content);
238
+ const updated = {
239
+ ...decoded,
240
+ issue: {
241
+ ...decoded.issue,
242
+ ...changes,
243
+ // Identity is not editable through update. Renumbering is a migration.
244
+ uid: decoded.issue.uid ?? changes.uid,
245
+ id: decoded.issue.id,
246
+ updated_at: new Date().toISOString(),
247
+ },
248
+ };
249
+ await writeFileAtomic(current.path, encodeIssue(updated));
250
+ return this.requireOne(current.filename);
251
+ }
252
+ async close(idOrFilename, resolution, status = 'closed') {
253
+ const now = new Date().toISOString();
254
+ return this.update(idOrFilename, { status, resolution, closed_at: now });
255
+ }
256
+ }
257
+ //# sourceMappingURL=issueRepository.js.map