@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
@@ -1,10 +1,15 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import { stringify as stringifyYaml } from 'yaml';
3
4
  import { log } from '../config.js';
4
5
  import { ensureDir } from '../dataRoot.js';
5
6
  import { resolveProjectPaths, validateWritePath } from '../projectRegistry.js';
6
7
  import { emitCreateProvenance } from './provenance.js';
7
8
  import { safeParseYaml } from '../sentinelIssues.js';
9
+ import { readStoreDir, storeMeta } from './shared/storeRead.js';
10
+ import { FsIssueRepository, AmbiguousIssueIdError as RepoAmbiguousIssueIdError, IssueNotFoundError as RepoIssueNotFoundError, } from '../domain/issueRepository.js';
11
+ import { writeFileAtomic } from '../lib/atomicWrite.js';
12
+ import { allocateAndWriteEpic } from '../lib/recordIdAllocator.js';
8
13
  /**
9
14
  * Sentinel record files exist in two on-disk formats: markdown-with-frontmatter
10
15
  * (`.md`) and bare YAML (`.yml`/`.yaml`). Historically every directory scan
@@ -82,61 +87,131 @@ function formatTimestampForFilename(date) {
82
87
  const iso = date.toISOString();
83
88
  return iso.replace(/:/g, '-').replace(/\.\d{3}Z$/, 'Z');
84
89
  }
85
- async function getNextEpicNumber(epicsDir) {
86
- try {
87
- const files = await fs.readdir(epicsDir);
88
- const epicNumbers = files
89
- .filter((f) => f.startsWith('EPIC-') && isRecordFile(f))
90
- .map((f) => {
91
- const match = f.match(/^EPIC-(\d+)/);
92
- return match ? parseInt(match[1], 10) : 0;
93
- });
94
- return epicNumbers.length > 0 ? Math.max(...epicNumbers) + 1 : 1;
95
- }
96
- catch {
97
- return 1;
98
- }
99
- }
100
- function formatEpicId(num) {
101
- return `EPIC-${num.toString().padStart(4, '0')}`;
102
- }
90
+ // getNextEpicNumber/formatEpicId lived here and were the epic half of the
91
+ // duplicate-id race: they read the directory for the current maximum, and the
92
+ // write happened ~20 awaits later with nothing holding the two together.
93
+ // Allocation now runs under the same cross-process lock as issues — see
94
+ // allocateAndWriteEpic in src/lib/recordIdAllocator.ts.
103
95
  function formatIssueId(num) {
104
96
  return `ISS-${num.toString().padStart(4, '0')}`;
105
97
  }
106
- async function getNextIssueNumber(issuesDir) {
107
- let max = 0;
108
- try {
109
- const files = await fs.readdir(issuesDir);
110
- for (const file of files) {
111
- const prefixMatch = file.match(/^ISS-(\d+)/i);
112
- if (prefixMatch) {
113
- max = Math.max(max, parseInt(prefixMatch[1], 10));
114
- continue;
115
- }
116
- if (!isRecordFile(file))
117
- continue;
118
- try {
119
- const content = await fs.readFile(path.join(issuesDir, file), 'utf-8');
120
- const region = frontmatterRegion(content);
121
- if (!region)
122
- continue;
123
- const idLine = region.split('\n').find((l) => l.trim().toLowerCase().startsWith('id:'));
124
- if (!idLine)
125
- continue;
126
- const idVal = idLine.slice(idLine.indexOf(':') + 1).trim();
127
- const idMatch = idVal.match(/^ISS-(\d+)$/i);
128
- if (idMatch)
129
- max = Math.max(max, parseInt(idMatch[1], 10));
98
+ /**
99
+ * The identity a writer would collide on: the padded ISS-NNNN from the
100
+ * filename, else from frontmatter, else the filename itself (which is unique
101
+ * by construction). Mirrors the tier-3/tier-4 matching in findIssueCandidates
102
+ * so the collision report and the write refusal agree on what "same id" means.
103
+ */
104
+ function canonicalIssueKey(filename, summaryId) {
105
+ const fromName = filename.match(/^ISS-(\d+)/i);
106
+ if (fromName)
107
+ return `ISS-${fromName[1].padStart(4, '0')}`.toUpperCase();
108
+ const fromFrontmatter = summaryId.match(/^ISS-(\d+)$/i);
109
+ if (fromFrontmatter)
110
+ return `ISS-${fromFrontmatter[1].padStart(4, '0')}`.toUpperCase();
111
+ return filename;
112
+ }
113
+ /**
114
+ * Coerce a parsed YAML mapping into the flat shape the epic reader expects.
115
+ * Scalars become strings, sequences become string arrays, absent stays absent.
116
+ */
117
+ function normalizeFrontmatter(parsed) {
118
+ const out = {};
119
+ for (const [key, value] of Object.entries(parsed)) {
120
+ if (value === null || value === undefined) {
121
+ out[key] = '';
122
+ }
123
+ else if (Array.isArray(value)) {
124
+ out[key] = value.map((v) => (typeof v === 'string' ? v : String(v)));
125
+ }
126
+ else if (typeof value === 'string') {
127
+ out[key] = value;
128
+ }
129
+ else if (value instanceof Date) {
130
+ out[key] = value.toISOString();
131
+ }
132
+ else if (typeof value === 'object') {
133
+ // A nested mapping has no representation in the flat epic model. Keep it
134
+ // legible rather than emitting "[object Object]".
135
+ out[key] = JSON.stringify(value);
136
+ }
137
+ else {
138
+ out[key] = String(value);
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * The pre-YAML reader: split each line on its first colon. Retained ONLY as a
145
+ * fallback for records YAML rejects — see parseEpicFrontmatter.
146
+ */
147
+ function parseLooseFrontmatter(raw) {
148
+ const frontmatter = {};
149
+ for (const line of raw.split('\n')) {
150
+ const colonIndex = line.indexOf(':');
151
+ if (colonIndex > 0) {
152
+ const key = line.slice(0, colonIndex).trim();
153
+ let value = line.slice(colonIndex + 1).trim();
154
+ // Handle arrays (simple format: [item1, item2])
155
+ if (value.startsWith('[') && value.endsWith(']')) {
156
+ frontmatter[key] = value
157
+ .slice(1, -1)
158
+ .split(',')
159
+ .map((s) => s.trim().replace(/^"(.*)"$/s, '$1'))
160
+ .filter((s) => s.length > 0);
130
161
  }
131
- catch {
132
- // skip unreadable files
162
+ else {
163
+ // Strip YAML-style outer quoting. yamlScalar writes values containing
164
+ // '#', ':' etc. as JSON-style double-quoted strings; without this,
165
+ // the naive parser returns the literal "..."-wrapped string.
166
+ if (value.length >= 2) {
167
+ if (value.startsWith('"') && value.endsWith('"')) {
168
+ try {
169
+ value = JSON.parse(value);
170
+ }
171
+ catch { /* keep as-is on bad escape */ }
172
+ }
173
+ else if (value.startsWith("'") && value.endsWith("'")) {
174
+ value = value.slice(1, -1).replace(/''/g, "'");
175
+ }
176
+ }
177
+ frontmatter[key] = value;
133
178
  }
134
179
  }
135
180
  }
181
+ return frontmatter;
182
+ }
183
+ /**
184
+ * Parse epic frontmatter, YAML first.
185
+ *
186
+ * The fenced (`.md`) branch used to hand-roll `key: value` splitting while the
187
+ * bare-YAML branch six lines above used a real parser. Splitting on the first
188
+ * colon reports a block-scalar INDICATOR as the value — `summary: |-` read back
189
+ * as the literal string "|-", losing the whole summary — and then treats every
190
+ * indented prose line containing a colon as its own key. On EPIC-0038 that
191
+ * produced 11 invented keys with names like "STILL LIVE" and "degraded".
192
+ *
193
+ * YAML cannot simply replace it. 7 of 38 epic records on disk have frontmatter
194
+ * a real parser rejects: unquoted values containing ": ", emitted by a write
195
+ * path that does not quote (`title: Epic: Special Characters!`). Parsing those
196
+ * strictly would return null and drop them from list_epics entirely — a worse
197
+ * defect than the one being fixed. So YAML when the record is valid, the loose
198
+ * reader when it is not.
199
+ *
200
+ * The loose path is a compatibility shim for existing damage, not a supported
201
+ * format. It is removable once the write side stops emitting unquoted scalars
202
+ * (EPIC-0038 Phase 1e) and the affected records are repaired.
203
+ */
204
+ function parseEpicFrontmatter(raw) {
205
+ try {
206
+ const parsed = safeParseYaml(raw);
207
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
208
+ return normalizeFrontmatter(parsed);
209
+ }
210
+ }
136
211
  catch {
137
- // dir doesn't exist yet
212
+ // Not valid YAML — fall through rather than dropping the record.
138
213
  }
139
- return max + 1;
214
+ return parseLooseFrontmatter(raw);
140
215
  }
141
216
  async function parseEpicFile(filePath) {
142
217
  try {
@@ -169,47 +244,53 @@ async function parseEpicFile(filePath) {
169
244
  owner: str('owner'),
170
245
  squad: str('squad'),
171
246
  created_at: str('created_at'),
247
+ // Bare-YAML epics have no markdown body, so there is nowhere for a
248
+ // `## Note (…)` section to live. Empty, not absent — callers should
249
+ // not have to distinguish "no notes" from "this format cannot hold
250
+ // notes".
251
+ notes: [],
172
252
  };
173
253
  }
174
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
254
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
175
255
  if (!frontmatterMatch)
176
256
  return null;
177
- const frontmatter = {};
178
- for (const line of frontmatterMatch[1].split('\n')) {
179
- const colonIndex = line.indexOf(':');
180
- if (colonIndex > 0) {
181
- const key = line.slice(0, colonIndex).trim();
182
- let value = line.slice(colonIndex + 1).trim();
183
- // Handle arrays (simple format: [item1, item2])
184
- if (value.startsWith('[') && value.endsWith(']')) {
185
- frontmatter[key] = value
186
- .slice(1, -1)
187
- .split(',')
188
- .map((s) => s.trim().replace(/^"(.*)"$/s, '$1'))
189
- .filter((s) => s.length > 0);
190
- }
191
- else {
192
- // Strip YAML-style outer quoting. yamlScalar writes values containing
193
- // '#', ':' etc. as JSON-style double-quoted strings; without this,
194
- // the naive parser returns the literal "..."-wrapped string.
195
- if (value.length >= 2) {
196
- if (value.startsWith('"') && value.endsWith('"')) {
197
- try {
198
- value = JSON.parse(value);
199
- }
200
- catch { /* keep as-is on bad escape */ }
201
- }
202
- else if (value.startsWith("'") && value.endsWith("'")) {
203
- value = value.slice(1, -1).replace(/''/g, "'");
204
- }
205
- }
206
- frontmatter[key] = value;
207
- }
208
- }
209
- }
257
+ const frontmatter = parseEpicFrontmatter(frontmatterMatch[1]);
210
258
  // Extract sections from body
211
259
  const bodyMatch = content.match(/---\n\n([\s\S]*)/);
212
260
  const body = bodyMatch ? bodyMatch[1] : '';
261
+ /**
262
+ * Notes are `## Note (<iso>)` sections appended by update_epic, so the
263
+ * reader is written against that exact shape rather than against any
264
+ * heading that looks note-ish.
265
+ *
266
+ * A note runs to the NEXT `## Note (…)` heading, not to the next `##`.
267
+ * That distinction is the whole bug: a note whose own text opens with a
268
+ * markdown heading — `## Resolution`, the commonest thing anyone writes in
269
+ * one — made the terminator match at offset zero, so the section came back
270
+ * empty and `if (text)` dropped it. read_epic then returned `notes: []` for
271
+ * an epic whose file plainly held the note, and update_epic had reported
272
+ * success, which is why it survived #72.
273
+ *
274
+ * Ending at the next NOTE heading is safe because notes are only ever
275
+ * appended after every other section: log_epic writes Summary, Motivation,
276
+ * Outcomes and Acceptance Criteria before any note can exist, and the one
277
+ * writer that could append a section later (the summary fallback below)
278
+ * now inserts ahead of the notes for this reason.
279
+ */
280
+ const extractNotes = () => {
281
+ const out = [];
282
+ const re = /^##\s+Note\s*\(([^)]*)\)\s*$/gm;
283
+ let m;
284
+ while ((m = re.exec(body)) !== null) {
285
+ const from = m.index + m[0].length;
286
+ const rest = body.slice(from);
287
+ const next = rest.search(/^##\s+Note\s*\([^)]*\)\s*$/m);
288
+ const text = (next === -1 ? rest : rest.slice(0, next)).trim();
289
+ if (text)
290
+ out.push({ at: m[1].trim(), text });
291
+ }
292
+ return out;
293
+ };
213
294
  const extractList = (section) => {
214
295
  const regex = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`);
215
296
  const match = body.match(regex);
@@ -233,6 +314,7 @@ async function parseEpicFile(filePath) {
233
314
  owner: frontmatter.owner || '',
234
315
  squad: frontmatter.squad || '',
235
316
  created_at: frontmatter.created_at || '',
317
+ notes: extractNotes(),
236
318
  };
237
319
  }
238
320
  catch {
@@ -398,21 +480,37 @@ function buildIssueSummary(filePath, content, parsed, degraded) {
398
480
  summary.degraded = true;
399
481
  return summary;
400
482
  }
401
- async function findIssueFile(projectId, issueId) {
483
+ /**
484
+ * Resolve an issue id to every file it could plausibly mean.
485
+ *
486
+ * Matching runs in tiers of decreasing specificity and returns only the FIRST
487
+ * NON-EMPTY tier. That ordering is load-bearing: an exact filename outranks an
488
+ * ISS-NNNN prefix, so passing a filename stays a reliable way to name one
489
+ * member of a duplicate-id pair. Ambiguity is only ever reported within a
490
+ * single tier, never across tiers.
491
+ *
492
+ * Returning an array rather than the first hit is the point. Duplicate ISS-NNNN
493
+ * ids exist in the wild (ISS-0131), and `files.find(...)` resolved them in
494
+ * fs.readdir order — so every writer had an undefined target, silently edited
495
+ * one record, silently left the other untouched, and returned success.
496
+ */
497
+ async function findIssueCandidates(projectId, issueId) {
402
498
  const resolved = resolveProjectPaths(projectId);
403
499
  const issuesDir = resolved.subPath('sentinel', 'issues');
500
+ const at = (filename) => ({ filePath: path.join(issuesDir, filename), filename });
404
501
  try {
405
502
  const files = await fs.readdir(issuesDir);
406
- // Try exact match first
503
+ // Tier 1: exact filename. A directory cannot hold two of these.
407
504
  if (files.includes(issueId)) {
408
- return { filePath: path.join(issuesDir, issueId), filename: issueId };
505
+ return [at(issueId)];
409
506
  }
410
- // Try each record extension (.md, .yml, .yaml)
507
+ // Tier 2: filename + record extension. At most one per extension, and the
508
+ // extension order (.md, .yml, .yaml) is the established precedence.
411
509
  if (!isRecordFile(issueId)) {
412
510
  for (const ext of ['.md', '.yml', '.yaml']) {
413
511
  const candidate = `${issueId}${ext}`;
414
512
  if (files.includes(candidate)) {
415
- return { filePath: path.join(issuesDir, candidate), filename: candidate };
513
+ return [at(candidate)];
416
514
  }
417
515
  }
418
516
  }
@@ -420,14 +518,16 @@ async function findIssueFile(projectId, issueId) {
420
518
  const issMatch = issueId.match(/^ISS-(\d+)$/i);
421
519
  if (issMatch) {
422
520
  const padded = `ISS-${issMatch[1].padStart(4, '0')}`.toLowerCase();
423
- const prefixHit = files.find((f) => {
521
+ // Tier 3: filename prefix. THIS is where duplicates collide.
522
+ const prefixHits = files.filter((f) => {
424
523
  const lower = f.toLowerCase();
425
524
  return lower.startsWith(`${padded}-`) || stripRecordExt(lower) === padded;
426
525
  });
427
- if (prefixHit) {
428
- return { filePath: path.join(issuesDir, prefixHit), filename: prefixHit };
526
+ if (prefixHits.length > 0) {
527
+ return prefixHits.map(at);
429
528
  }
430
- // Scan frontmatter for legacy retroactively-stamped issues
529
+ // Tier 4: frontmatter id, for legacy retroactively-stamped issues.
530
+ const fmHits = [];
431
531
  for (const f of files) {
432
532
  if (!isRecordFile(f))
433
533
  continue;
@@ -440,24 +540,23 @@ async function findIssueFile(projectId, issueId) {
440
540
  if (!idLine)
441
541
  continue;
442
542
  const idVal = idLine.slice(idLine.indexOf(':') + 1).trim().toLowerCase();
443
- if (idVal === padded) {
444
- return { filePath: path.join(issuesDir, f), filename: f };
445
- }
543
+ if (idVal === padded)
544
+ fmHits.push(f);
446
545
  }
447
546
  catch {
448
547
  // skip unreadable
449
548
  }
450
549
  }
550
+ if (fmHits.length > 0)
551
+ return fmHits.map(at);
451
552
  }
452
- // Fuzzy match - find file containing the query
453
- const match = files.find(f => f.toLowerCase().includes(issueId.toLowerCase()));
454
- if (match) {
455
- return { filePath: path.join(issuesDir, match), filename: match };
456
- }
457
- return null;
553
+ // Tier 5: fuzzy substring. Genuinely ambiguous when it hits more than once
554
+ // (`ISS-011` spans ISS-0110, ISS-0112, ISS-0119), so it reports all of them.
555
+ const fuzzyHits = files.filter((f) => f.toLowerCase().includes(issueId.toLowerCase()));
556
+ return fuzzyHits.map(at);
458
557
  }
459
558
  catch {
460
- return null;
559
+ return [];
461
560
  }
462
561
  }
463
562
  async function getProjectIssues(projectId) {
@@ -485,7 +584,6 @@ async function getProjectIssues(projectId) {
485
584
  // Issue Functions
486
585
  // ============================================================================
487
586
  export async function createIssue(input) {
488
- // Resolve project paths first - fail fast if no project
489
587
  let resolved;
490
588
  try {
491
589
  resolved = resolveProjectPaths(input.projectId);
@@ -493,67 +591,46 @@ export async function createIssue(input) {
493
591
  catch (err) {
494
592
  return makeProjectResolutionError('create issue');
495
593
  }
496
- const issuesDir = resolved.subPath('sentinel', 'issues');
497
- // Validate epic_id if provided
594
+ // Epic validation stays here: it is a policy question about this project's
595
+ // epics, not a storage concern, so it does not belong behind the repository.
498
596
  if (input.epic_id) {
499
597
  const allEpics = await getAllEpics(input.projectId);
500
- const epicExists = allEpics.some((e) => e.id === input.epic_id);
501
- if (!epicExists) {
502
- // Return structured error with suggestions
503
- const suggested = allEpics
504
- .slice(0, 5)
505
- .map((e) => ({ id: e.id, title: e.title }));
598
+ if (!allEpics.some((e) => e.id === input.epic_id)) {
506
599
  return {
507
600
  error: 'EPIC_NOT_FOUND',
508
601
  epic_id: input.epic_id,
509
602
  message: `Unknown epic_id ${input.epic_id}.`,
510
- suggested_epics: suggested,
603
+ suggested_epics: allEpics.slice(0, 5).map((e) => ({ id: e.id, title: e.title })),
511
604
  };
512
605
  }
513
606
  }
514
- const now = new Date();
515
- const timestamp = now.toISOString();
516
- const slug = slugify(input.title);
517
- ensureDir(issuesDir);
518
- const issueNum = await getNextIssueNumber(issuesDir);
519
- const issueId = formatIssueId(issueNum);
520
- const filename = `${issueId}-${slug}.md`;
521
- const filePath = path.join(issuesDir, filename);
522
- validateWritePath(filePath, resolved);
523
- // Build frontmatter with optional epic_id
524
- const frontmatterLines = [
525
- '---',
526
- `id: ${issueId}`,
527
- `projectId: ${resolved.id}`,
528
- `severity: ${input.severity}`,
529
- `status: open`,
530
- `created_at: ${timestamp}`,
531
- ];
532
- if (input.epic_id) {
533
- frontmatterLines.push(`epic_id: ${input.epic_id}`);
534
- }
535
- frontmatterLines.push('---');
536
- const frontmatter = frontmatterLines.join('\n');
537
- const bodyLines = [
538
- `# ${input.title}`,
539
- '',
540
- `**Severity:** ${input.severity}`,
541
- `**Status:** open`,
542
- ];
543
- if (input.epic_id) {
544
- bodyLines.push(`**Epic:** ${input.epic_id}`);
545
- }
546
- bodyLines.push('', '## Details', '', input.details);
547
- const body = bodyLines.join('\n');
548
- const content = `${frontmatter}\n\n${body}\n`;
549
- await fs.writeFile(filePath, content, 'utf-8');
550
- log(`Sentinel: Created issue at ${filePath} (project: ${resolved.id})`);
551
- // Emit provenance event for this creation
552
- await emitCreateProvenance(`sentinel:issue:${filename}`, content, `Created issue: ${input.title}`, input.projectId);
607
+ const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
608
+ // The repository allocates and writes under one cross-process lock and stamps
609
+ // a uid at birth — which the previous writer did not, so every issue filed
610
+ // since the Phase 3 backfill was landing without a stable identity.
611
+ //
612
+ // Path traversal is not reachable here: the filename is built from an
613
+ // allocated ISS-NNNN plus a slug that strips everything outside [a-z0-9-],
614
+ // so a hostile title cannot escape the issues directory.
615
+ const created = await repo.create({
616
+ title: input.title,
617
+ details: input.details,
618
+ severity: input.severity,
619
+ epicId: input.epic_id,
620
+ project: resolved.id,
621
+ // Forwarded rather than dropped. CreateIssueSpec has accepted these all
622
+ // along; omitting them here meant a caller could pass priority:'high' and
623
+ // watch it vanish with no error, because create_issue's schema allows
624
+ // additional properties.
625
+ priority: input.priority,
626
+ tags: input.tags,
627
+ });
628
+ log(`Sentinel: Created issue at ${created.path} (project: ${resolved.id})`);
629
+ await emitCreateProvenance(`sentinel:issue:${created.filename}`, await fs.readFile(created.path, 'utf-8'), `Created issue: ${input.title}`, input.projectId);
553
630
  return {
554
- id: issueId,
555
- timestamp,
556
- path: filePath,
631
+ id: created.issue.id,
632
+ timestamp: created.issue.created_at ?? new Date().toISOString(),
633
+ path: created.path,
557
634
  status: 'open',
558
635
  epic_id: input.epic_id,
559
636
  location: 'project',
@@ -627,7 +704,6 @@ function setYamlBlockField(lines, key, value) {
627
704
  return out;
628
705
  }
629
706
  export async function closeIssue(input) {
630
- // Resolve project paths first
631
707
  let resolved;
632
708
  try {
633
709
  resolved = resolveProjectPaths(input.projectId);
@@ -635,85 +711,59 @@ export async function closeIssue(input) {
635
711
  catch (err) {
636
712
  return makeProjectResolutionError('close issue');
637
713
  }
638
- const found = await findIssueFile(input.projectId, input.issue_id);
639
- if (!found) {
640
- const available = await getProjectIssues(input.projectId);
714
+ const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
715
+ try {
716
+ // One call replaces the format fork this function used to carry. The
717
+ // bare-YAML branch was where close_issue historically failed: its
718
+ // frontmatter-anchored regexes silently no-opped on a delimiter-less
719
+ // record, so status stayed `open` while the markdown resolution append
720
+ // still fired — corrupting the YAML and reporting success. The codec has
721
+ // one write path for both formats, so that divergence cannot recur.
722
+ const closed = await repo.close(input.issue_id, input.resolution ?? '', input.status ?? 'closed');
723
+ log(`Sentinel: Closed issue at ${closed.path}`);
641
724
  return {
642
- error: 'ISSUE_NOT_FOUND',
643
- projectId: resolved.id,
644
- issue_id: input.issue_id,
645
- message: `Could not find issue matching "${input.issue_id}" in project "${resolved.id}".`,
646
- available_issues: available.slice(0, 5),
725
+ id: closed.issue.id,
726
+ path: closed.path,
727
+ status: closed.issue.status,
728
+ closed_at: closed.issue.closed_at ?? new Date().toISOString(),
729
+ resolution: input.resolution,
647
730
  };
648
731
  }
649
- const { filePath, filename } = found;
650
- const content = await fs.readFile(filePath, 'utf-8');
651
- const now = new Date();
652
- const closedAt = now.toISOString();
653
- const newStatus = input.status || 'closed';
654
- let updatedContent;
655
- if (/^---\r?\n/.test(content)) {
656
- // ---- Markdown-with-frontmatter record ------------------------------------
657
- updatedContent = content.replace(/^(---\n[\s\S]*?)status: \w+/m, `$1status: ${newStatus}`);
658
- // Add closed_at to frontmatter if not present
659
- if (!updatedContent.includes('closed_at:')) {
660
- updatedContent = updatedContent.replace(/^(---\n[\s\S]*?)(---)$/m, `$1closed_at: ${closedAt}\n$2`);
661
- }
662
- else {
663
- updatedContent = updatedContent.replace(/closed_at: .*/, `closed_at: ${closedAt}`);
732
+ catch (err) {
733
+ if (err instanceof RepoAmbiguousIssueIdError) {
734
+ return {
735
+ error: 'AMBIGUOUS_ISSUE_ID',
736
+ projectId: resolved.id,
737
+ issue_id: input.issue_id,
738
+ message: `"${input.issue_id}" matches ${err.candidates.length} records in project "${resolved.id}", ` +
739
+ `so the write target is undefined and nothing was changed. ` +
740
+ `Re-run with one of these filenames as issue_id: ${err.candidates.join(', ')}`,
741
+ candidates: err.candidates,
742
+ };
664
743
  }
665
- // Update status in body
666
- updatedContent = updatedContent.replace(/\*\*Status:\*\* \w+/, `**Status:** ${newStatus}`);
667
- // Add resolution section if provided
668
- if (input.resolution) {
669
- if (updatedContent.includes('## Resolution')) {
670
- // Replace existing resolution
671
- updatedContent = updatedContent.replace(/## Resolution\n\n[\s\S]*?(?=\n## |$)/, `## Resolution\n\n${input.resolution}\n`);
672
- }
673
- else {
674
- // Add resolution section at the end
675
- updatedContent = updatedContent.trimEnd() + `\n\n## Resolution\n\n${input.resolution}\n`;
676
- }
744
+ if (err instanceof RepoIssueNotFoundError) {
745
+ const available = await getProjectIssues(input.projectId);
746
+ return {
747
+ error: 'ISSUE_NOT_FOUND',
748
+ projectId: resolved.id,
749
+ issue_id: input.issue_id,
750
+ message: `Could not find issue matching "${input.issue_id}" in project "${resolved.id}".`,
751
+ available_issues: available.slice(0, 5),
752
+ };
677
753
  }
754
+ throw err;
678
755
  }
679
- else {
680
- // ---- Bare-YAML record ----------------------------------------------------
681
- // The frontmatter regexes above are all anchored on a leading `---`, so on
682
- // a bare-YAML record they silently no-op: status stayed `open` while the
683
- // markdown resolution append (which had no such guard) still fired. That
684
- // both corrupted the YAML and left the issue looking open, while
685
- // close_issue returned success. Write real YAML fields instead.
686
- const { yaml, tail } = splitBareYamlRecord(content);
687
- let region = setYamlScalarField(yaml, 'status', newStatus);
688
- region = setYamlScalarField(region, 'closed_at', closedAt);
689
- if (input.resolution) {
690
- region = setYamlBlockField(region, 'resolution', input.resolution);
691
- }
692
- // Preserve any pre-existing markdown tail verbatim — repairing already
693
- // corrupted files is a separate, reviewed migration (see ISS-0129).
694
- const parts = tail.length > 0 ? [...region, '', ...tail] : region;
695
- updatedContent = parts.join('\n').replace(/\n*$/, '\n');
696
- }
697
- await fs.writeFile(filePath, updatedContent, 'utf-8');
698
- log(`Sentinel: Closed issue at ${filePath}`);
699
- // Prefer ISS-NNNN id from filename or frontmatter for the return value
700
- const filenamePrefix = filename.match(/^(ISS-\d+)/i)?.[1];
701
- const fmIdMatch = updatedContent.match(/^---\n([\s\S]*?)\n---/);
702
- const fmId = fmIdMatch?.[1]
703
- .split('\n')
704
- .find((l) => l.trim().toLowerCase().startsWith('id:'))
705
- ?.split(':')[1]
706
- ?.trim()
707
- ?.match(/^ISS-\d+$/i)?.[0];
708
- const returnedId = filenamePrefix ?? fmId ?? filename;
709
- return {
710
- id: returnedId,
711
- path: filePath,
712
- status: newStatus,
713
- closed_at: closedAt,
714
- resolution: input.resolution,
715
- };
716
756
  }
757
+ /**
758
+ * Delegates to the Phase 3 repository. The output contract is unchanged — the
759
+ * point of the seam is that callers cannot tell which layer answered.
760
+ *
761
+ * `degraded` changes meaning slightly and for the better: it used to mean "only
762
+ * parsed after salvaging corrupted YAML", and now means "readable, but the
763
+ * codec had to interpret something" — a superset that also catches a legacy
764
+ * status value or a title that fell back to the filename. Both are the same
765
+ * repair queue.
766
+ */
717
767
  export async function listRepoIssues(input) {
718
768
  let resolved;
719
769
  try {
@@ -722,47 +772,53 @@ export async function listRepoIssues(input) {
722
772
  catch (err) {
723
773
  return makeProjectResolutionError('list issues');
724
774
  }
725
- const issuesDir = resolved.subPath('sentinel', 'issues');
726
- const issues = [];
727
- const malformedFiles = [];
728
- const degradedFiles = [];
729
- try {
730
- const files = await fs.readdir(issuesDir);
731
- for (const file of files) {
732
- if (!isRecordFile(file))
733
- continue;
734
- const filePath = path.join(issuesDir, file);
735
- const issue = await parseIssueFile(filePath);
736
- if (issue) {
737
- if (issue.degraded)
738
- degradedFiles.push(file);
739
- // Apply status filter
740
- if (input.status && issue.status !== input.status)
741
- continue;
742
- issues.push(issue);
743
- }
744
- else {
745
- // Unparseable file: report it instead of silently shrinking the list
746
- malformedFiles.push(file);
747
- }
748
- }
749
- }
750
- catch {
751
- // Directory doesn't exist
752
- }
753
- // Sort by filename (newest first based on timestamp)
775
+ const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
776
+ const [records, integrity] = await Promise.all([
777
+ repo.list(input.status ? { status: input.status } : {}),
778
+ repo.integrity(),
779
+ ]);
780
+ const issues = records.map((r) => toIssueSummary(r, integrity));
754
781
  issues.sort((a, b) => b.id.localeCompare(a.id));
755
782
  const out = { issues };
756
- if (malformedFiles.length > 0) {
757
- out.malformed = malformedFiles.length;
758
- out.malformed_files = malformedFiles;
783
+ if (integrity.malformed.length > 0) {
784
+ out.malformed = integrity.malformed.length;
785
+ out.malformed_files = integrity.malformed;
786
+ }
787
+ if (integrity.degraded.length > 0) {
788
+ out.degraded = integrity.degraded.length;
789
+ out.degraded_files = integrity.degraded.map((d) => d.filename);
759
790
  }
760
- if (degradedFiles.length > 0) {
761
- out.degraded = degradedFiles.length;
762
- out.degraded_files = degradedFiles;
791
+ const dupEntries = Object.entries(integrity.duplicateIds);
792
+ if (dupEntries.length > 0) {
793
+ out.duplicate_ids = dupEntries.length;
794
+ out.duplicate_id_files = Object.fromEntries(dupEntries);
763
795
  }
764
796
  return out;
765
797
  }
798
+ /** Canonical record -> the wire shape this module has always returned. */
799
+ function toIssueSummary(r, integrity) {
800
+ // Records with no ISS-NNNN have always been identified on the wire by their
801
+ // filename INCLUDING the extension, and close_issue/read_issue accept that
802
+ // form. The canonical model drops the extension, which is tidier and which
803
+ // the repository resolves either way — but changing it here would be a wire
804
+ // contract change smuggled in on a refactor. ISS-0113 tracks the
805
+ // inconsistency deliberately; this migration stays behaviour-preserving.
806
+ const wireId = /^ISS-\d+$/i.test(r.issue.id) ? r.issue.id : r.filename;
807
+ const summary = {
808
+ id: wireId,
809
+ title: r.issue.title,
810
+ severity: r.issue.severity ?? 'low',
811
+ status: r.issue.status,
812
+ epic_id: r.issue.epicId,
813
+ priority: r.issue.priority,
814
+ tags: r.issue.tags,
815
+ created_at: r.issue.created_at,
816
+ updated_at: r.issue.updated_at,
817
+ };
818
+ if (integrity.degraded.some((d) => d.filename === r.filename))
819
+ summary.degraded = true;
820
+ return summary;
821
+ }
766
822
  // ============================================================================
767
823
  // Epic Functions
768
824
  // ============================================================================
@@ -778,55 +834,57 @@ export async function logEpic(input) {
778
834
  const now = new Date();
779
835
  const timestamp = now.toISOString();
780
836
  ensureDir(epicsDir);
781
- const epicNum = await getNextEpicNumber(epicsDir);
782
- const epicId = formatEpicId(epicNum);
783
837
  const slug = slugify(input.title);
784
- const filename = `${epicId}-${slug}.md`;
785
- const filePath = path.join(epicsDir, filename);
786
- validateWritePath(filePath, resolved);
787
838
  const priority = input.priority || 'medium';
788
839
  const tags = input.tags || [];
789
840
  const owner = input.owner || '';
790
841
  const squad = input.squad || '';
791
- // Build frontmatter — use yamlScalar on free-text fields so values containing
792
- // `#`, `:` etc. don't truncate on read-back (see ISS-0110 bug 3).
793
- const frontmatter = [
794
- '---',
795
- `id: ${epicId}`,
796
- `projectId: ${yamlScalar(resolved.id)}`,
797
- `title: ${yamlScalar(input.title)}`,
798
- `summary: ${yamlScalar(input.summary)}`,
799
- `status: planned`,
800
- `priority: ${priority}`,
801
- `tags: [${tags.map(yamlScalar).join(', ')}]`,
802
- `owner: ${yamlScalar(owner)}`,
803
- `squad: ${yamlScalar(squad)}`,
804
- `created_at: ${timestamp}`,
805
- '---',
806
- ].join('\n');
807
- // Build body sections
808
- const sections = [];
809
- sections.push(`# ${input.title}`, '', '## Summary', '', input.summary);
810
- if (input.motivation && input.motivation.length > 0) {
811
- sections.push('', '## Motivation', '');
812
- for (const item of input.motivation) {
813
- sections.push(`- ${item}`);
842
+ // Content is built from the id the allocator hands out, so it cannot be
843
+ // assembled before allocation — the id appears in the frontmatter.
844
+ const buildContent = (epicId) => {
845
+ // Build frontmatter — use yamlScalar on free-text fields so values containing
846
+ // `#`, `:` etc. don't truncate on read-back (see ISS-0110 bug 3).
847
+ const frontmatter = [
848
+ '---',
849
+ `id: ${epicId}`,
850
+ `projectId: ${yamlScalar(resolved.id)}`,
851
+ `title: ${yamlScalar(input.title)}`,
852
+ `summary: ${yamlScalar(input.summary)}`,
853
+ `status: planned`,
854
+ `priority: ${priority}`,
855
+ `tags: [${tags.map(yamlScalar).join(', ')}]`,
856
+ `owner: ${yamlScalar(owner)}`,
857
+ `squad: ${yamlScalar(squad)}`,
858
+ `created_at: ${timestamp}`,
859
+ '---',
860
+ ].join('\n');
861
+ // Build body sections
862
+ const sections = [];
863
+ sections.push(`# ${input.title}`, '', '## Summary', '', input.summary);
864
+ if (input.motivation && input.motivation.length > 0) {
865
+ sections.push('', '## Motivation', '');
866
+ for (const item of input.motivation) {
867
+ sections.push(`- ${item}`);
868
+ }
814
869
  }
815
- }
816
- if (input.outcomes && input.outcomes.length > 0) {
817
- sections.push('', '## Outcomes', '');
818
- for (const item of input.outcomes) {
819
- sections.push(`- ${item}`);
870
+ if (input.outcomes && input.outcomes.length > 0) {
871
+ sections.push('', '## Outcomes', '');
872
+ for (const item of input.outcomes) {
873
+ sections.push(`- ${item}`);
874
+ }
820
875
  }
821
- }
822
- if (input.acceptance_criteria && input.acceptance_criteria.length > 0) {
823
- sections.push('', '## Acceptance Criteria', '');
824
- for (const item of input.acceptance_criteria) {
825
- sections.push(`- [ ] ${item}`);
876
+ if (input.acceptance_criteria && input.acceptance_criteria.length > 0) {
877
+ sections.push('', '## Acceptance Criteria', '');
878
+ for (const item of input.acceptance_criteria) {
879
+ sections.push(`- [ ] ${item}`);
880
+ }
826
881
  }
827
- }
828
- const content = `${frontmatter}\n\n${sections.join('\n')}\n`;
829
- await fs.writeFile(filePath, content, 'utf-8');
882
+ return `${frontmatter}\n\n${sections.join('\n')}\n`;
883
+ };
884
+ // Capture what actually landed rather than rebuilding it, so the provenance
885
+ // fingerprint below is of the bytes on disk and not of a second render.
886
+ let content = '';
887
+ const { id: epicId, filePath } = await allocateAndWriteEpic(epicsDir, slug, (id) => (content = buildContent(id)), { validate: (p) => validateWritePath(p, resolved) });
830
888
  log(`Sentinel: Created epic at ${filePath} (project: ${resolved.id})`);
831
889
  // Emit provenance event for this creation
832
890
  await emitCreateProvenance(`sentinel:epic:${epicId}`, content, `Created epic: ${input.title}`, input.projectId);
@@ -847,39 +905,31 @@ export async function listEpics(input) {
847
905
  }
848
906
  const epicsDir = resolved.subPath('sentinel', 'epics');
849
907
  const epics = [];
850
- try {
851
- const files = await fs.readdir(epicsDir);
852
- for (const file of files) {
853
- if (!isRecordFile(file))
854
- continue;
855
- const filePath = path.join(epicsDir, file);
856
- const epic = await parseEpicFile(filePath);
857
- if (!epic)
858
- continue;
859
- // Apply filters
860
- if (input.status && epic.status !== input.status)
861
- continue;
862
- if (input.priority && epic.priority !== input.priority)
908
+ // ISS-0153. The previous form was `catch { /* directory doesn't exist yet */ }`
909
+ // wrapped around BOTH the readdir and the parse loop, with `if (!epic)
910
+ // continue` inside it. So a permissions failure, a corrupt epic, and a project
911
+ // that has never logged one all returned `{epics: []}` — and the caller could
912
+ // not tell which. readStoreDir keeps them apart and counts what it skipped.
913
+ const read = await readStoreDir(epicsDir, parseEpicFile, isRecordFile);
914
+ for (const epic of read.entries) {
915
+ if (input.status && epic.status !== input.status)
916
+ continue;
917
+ if (input.priority && epic.priority !== input.priority)
918
+ continue;
919
+ if (input.tags && input.tags.length > 0) {
920
+ if (!input.tags.some((t) => epic.tags.includes(t)))
863
921
  continue;
864
- if (input.tags && input.tags.length > 0) {
865
- const hasTag = input.tags.some((t) => epic.tags.includes(t));
866
- if (!hasTag)
867
- continue;
868
- }
869
- epics.push({
870
- id: epic.id,
871
- title: epic.title,
872
- status: epic.status,
873
- priority: epic.priority,
874
- });
875
922
  }
876
- }
877
- catch {
878
- // Directory doesn't exist yet
923
+ epics.push({
924
+ id: epic.id,
925
+ title: epic.title,
926
+ status: epic.status,
927
+ priority: epic.priority,
928
+ });
879
929
  }
880
930
  // Sort by ID (newest first based on number)
881
931
  epics.sort((a, b) => b.id.localeCompare(a.id));
882
- return { epics };
932
+ return { epics, ...storeMeta(read) };
883
933
  }
884
934
  export async function getEpic(input) {
885
935
  let resolved;
@@ -907,6 +957,172 @@ export async function getEpic(input) {
907
957
  return { epic: null, error: `Epic not found: ${input.epic_id}` };
908
958
  }
909
959
  }
960
+ /**
961
+ * Locate the file backing an epic id.
962
+ *
963
+ * Boundary-matched deliberately. A bare `startsWith` has no boundary, so
964
+ * `EPIC-003` would match EPIC-0030, EPIC-0031 and EPIC-0038 at once and the
965
+ * caller would silently write to whichever readdir returned first — the same
966
+ * defect that made issue resolution unsafe (ISS-0131). Returning every match
967
+ * lets the caller refuse rather than guess.
968
+ */
969
+ async function findEpicFiles(epicsDir, epicId) {
970
+ let files;
971
+ try {
972
+ files = await fs.readdir(epicsDir);
973
+ }
974
+ catch {
975
+ return [];
976
+ }
977
+ const padded = epicId.match(/^EPIC-(\d+)$/i);
978
+ const canonical = padded ? `epic-${padded[1].padStart(4, '0')}` : epicId.toLowerCase();
979
+ return files.filter((f) => {
980
+ if (!isRecordFile(f))
981
+ return false;
982
+ const lower = f.toLowerCase();
983
+ // Exact filename, or the id followed by a non-digit separator.
984
+ return lower === epicId.toLowerCase() || new RegExp(`^${canonical}(?![0-9])`).test(lower);
985
+ });
986
+ }
987
+ /**
988
+ * Update an epic in place.
989
+ *
990
+ * Epics were write-once: log_epic created them and nothing could change status,
991
+ * priority, summary, or ownership afterwards (ISS-0140). The practical effects
992
+ * were that `status` stayed `planned` forever — making list_epics(status:...)
993
+ * useless and feeding wrong epic state into oracle/roadmap reporting
994
+ * (ISS-0110) — and that correcting an epic meant hand-editing the file, which
995
+ * is the markdown-into-YAML write hazard that corrupted issue records in the
996
+ * first place.
997
+ *
998
+ * Frontmatter is mutated as a parsed object and re-serialized, never patched by
999
+ * regex, and the file is replaced atomically. The body's `## Summary` section
1000
+ * mirrors the frontmatter `summary` field, so both are updated together —
1001
+ * leaving them to drift is what makes hand-editing an epic error-prone.
1002
+ */
1003
+ export async function updateEpic(input) {
1004
+ let resolved;
1005
+ try {
1006
+ resolved = resolveProjectPaths(input.projectId);
1007
+ }
1008
+ catch {
1009
+ return makeProjectResolutionError('update epic');
1010
+ }
1011
+ const epicsDir = resolved.subPath('sentinel', 'epics');
1012
+ const matches = await findEpicFiles(epicsDir, input.epic_id);
1013
+ if (matches.length === 0) {
1014
+ const all = await getAllEpics(input.projectId);
1015
+ return {
1016
+ error: 'EPIC_NOT_FOUND',
1017
+ epic_id: input.epic_id,
1018
+ message: `Unknown epic_id ${input.epic_id}.`,
1019
+ suggested_epics: all.slice(0, 5).map((e) => ({ id: e.id, title: e.title })),
1020
+ };
1021
+ }
1022
+ if (matches.length > 1) {
1023
+ return {
1024
+ error: 'AMBIGUOUS_EPIC_ID',
1025
+ epic_id: input.epic_id,
1026
+ message: `${matches.length} epic records match "${input.epic_id}". ` +
1027
+ `Pass an exact filename to disambiguate.`,
1028
+ candidates: matches,
1029
+ };
1030
+ }
1031
+ const filePath = path.join(epicsDir, matches[0]);
1032
+ validateWritePath(filePath, resolved);
1033
+ const content = await fs.readFile(filePath, 'utf-8');
1034
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
1035
+ if (!fmMatch) {
1036
+ return {
1037
+ error: 'EPIC_NOT_FOUND',
1038
+ epic_id: input.epic_id,
1039
+ message: `Epic record ${matches[0]} has no parseable frontmatter; refusing to write into a malformed file.`,
1040
+ suggested_epics: [],
1041
+ };
1042
+ }
1043
+ let parsed;
1044
+ try {
1045
+ parsed = safeParseYaml(fmMatch[1]);
1046
+ }
1047
+ catch (err) {
1048
+ return {
1049
+ error: 'EPIC_NOT_FOUND',
1050
+ epic_id: input.epic_id,
1051
+ message: `Epic record ${matches[0]} has unparseable frontmatter (${err instanceof Error ? err.message : String(err)}); refusing to write into it.`,
1052
+ suggested_epics: [],
1053
+ };
1054
+ }
1055
+ let body = fmMatch[2];
1056
+ const changes = [];
1057
+ const applyScalar = (key, value, label = key) => {
1058
+ if (value === undefined)
1059
+ return;
1060
+ const before = parsed[key];
1061
+ if (before === value)
1062
+ return;
1063
+ parsed[key] = value;
1064
+ changes.push(`${label}: ${before ?? 'unset'} → ${value}`);
1065
+ };
1066
+ applyScalar('status', input.status);
1067
+ applyScalar('priority', input.priority);
1068
+ applyScalar('title', input.title);
1069
+ applyScalar('owner', input.owner);
1070
+ applyScalar('squad', input.squad);
1071
+ if (input.tags !== undefined) {
1072
+ parsed.tags = input.tags;
1073
+ changes.push(`tags: ${input.tags.length} tag(s)`);
1074
+ }
1075
+ if (input.summary !== undefined) {
1076
+ parsed.summary = input.summary;
1077
+ changes.push('summary rewritten');
1078
+ // Keep the body's Summary section in step with frontmatter. They are two
1079
+ // copies of one value; letting them diverge is what makes an epic
1080
+ // untrustworthy to read.
1081
+ const summarySection = /^## Summary\s*\n[\s\S]*?(?=\n## |\s*$)/m;
1082
+ const replacement = `## Summary\n\n${input.summary}\n`;
1083
+ if (summarySection.test(body)) {
1084
+ body = body.replace(summarySection, replacement);
1085
+ }
1086
+ else {
1087
+ // Ahead of the notes, not after them. A note section ends at the next
1088
+ // note heading (see extractNotes), so a Summary appended past the last
1089
+ // note would be read back as part of that note's text.
1090
+ const firstNote = body.search(/^##\s+Note\s*\([^)]*\)\s*$/m);
1091
+ body = firstNote === -1
1092
+ ? `${body.trimEnd()}\n\n${replacement}`
1093
+ : `${body.slice(0, firstNote).trimEnd()}\n\n${replacement}\n${body.slice(firstNote)}`;
1094
+ }
1095
+ }
1096
+ if (input.note) {
1097
+ const stamp = new Date().toISOString();
1098
+ body = `${body.trimEnd()}\n\n## Note (${stamp})\n\n${input.note}\n`;
1099
+ changes.push('note appended');
1100
+ }
1101
+ if (changes.length === 0) {
1102
+ return {
1103
+ id: String(parsed.id ?? input.epic_id),
1104
+ path: filePath,
1105
+ status: parsed.status ?? 'planned',
1106
+ priority: parsed.priority ?? 'medium',
1107
+ updated_at: String(parsed.updated_at ?? ''),
1108
+ changes: [],
1109
+ };
1110
+ }
1111
+ const updatedAt = new Date().toISOString();
1112
+ parsed.updated_at = updatedAt;
1113
+ const frontmatter = stringifyYaml(parsed, { lineWidth: 0 }).trimEnd();
1114
+ const rebuilt = `---\n${frontmatter}\n---\n\n${body.replace(/^\n+/, '').trimEnd()}\n`;
1115
+ await writeFileAtomic(filePath, rebuilt);
1116
+ log(`Sentinel: Updated epic ${parsed.id} at ${filePath} — ${changes.join(', ')}`);
1117
+ return {
1118
+ id: String(parsed.id ?? input.epic_id),
1119
+ path: filePath,
1120
+ status: parsed.status ?? 'planned',
1121
+ priority: parsed.priority ?? 'medium',
1122
+ updated_at: updatedAt,
1123
+ changes,
1124
+ };
1125
+ }
910
1126
  export async function getEpicIssues(input) {
911
1127
  let resolved;
912
1128
  try {