@decibelsystems/tools 2.1.4 → 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.
- package/CHANGELOG.md +160 -0
- package/README.md +1 -1
- package/dist/agentPresence.d.ts +80 -0
- package/dist/agentPresence.d.ts.map +1 -1
- package/dist/agentPresence.js +177 -0
- package/dist/agentPresence.js.map +1 -1
- package/dist/agentic/index.d.ts +18 -8
- package/dist/agentic/index.d.ts.map +1 -1
- package/dist/agentic/index.js +18 -144
- package/dist/agentic/index.js.map +1 -1
- package/dist/agentic/types.d.ts +106 -0
- package/dist/agentic/types.d.ts.map +1 -1
- package/dist/agentic/types.js +77 -1
- package/dist/agentic/types.js.map +1 -1
- package/dist/architectAdrs.d.ts +14 -2
- package/dist/architectAdrs.d.ts.map +1 -1
- package/dist/architectAdrs.js +9 -3
- package/dist/architectAdrs.js.map +1 -1
- package/dist/client/transports.d.ts.map +1 -1
- package/dist/client/transports.js +13 -3
- package/dist/client/transports.js.map +1 -1
- package/dist/daemon.d.ts +19 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +181 -38
- package/dist/daemon.js.map +1 -1
- package/dist/daemonConfig.d.ts +30 -0
- package/dist/daemonConfig.d.ts.map +1 -1
- package/dist/daemonConfig.js +56 -9
- package/dist/daemonConfig.js.map +1 -1
- package/dist/decibelPaths.d.ts.map +1 -1
- package/dist/decibelPaths.js +39 -6
- package/dist/decibelPaths.js.map +1 -1
- package/dist/domain/issue.d.ts +77 -0
- package/dist/domain/issue.d.ts.map +1 -0
- package/dist/domain/issue.js +134 -0
- package/dist/domain/issue.js.map +1 -0
- package/dist/domain/issueCodec.d.ts +39 -0
- package/dist/domain/issueCodec.d.ts.map +1 -0
- package/dist/domain/issueCodec.js +298 -0
- package/dist/domain/issueCodec.js.map +1 -0
- package/dist/domain/issueRepository.d.ts +91 -0
- package/dist/domain/issueRepository.d.ts.map +1 -0
- package/dist/domain/issueRepository.js +257 -0
- package/dist/domain/issueRepository.js.map +1 -0
- package/dist/facades/definitions.d.ts +0 -1
- package/dist/facades/definitions.d.ts.map +1 -1
- package/dist/facades/definitions.js +94 -98
- package/dist/facades/definitions.js.map +1 -1
- package/dist/facades/index.d.ts +2 -1
- package/dist/facades/index.d.ts.map +1 -1
- package/dist/facades/index.js +1 -1
- package/dist/facades/index.js.map +1 -1
- package/dist/facades/types.d.ts +17 -0
- package/dist/facades/types.d.ts.map +1 -1
- package/dist/httpArgs.d.ts +11 -0
- package/dist/httpArgs.d.ts.map +1 -0
- package/dist/httpArgs.js +39 -0
- package/dist/httpArgs.js.map +1 -0
- package/dist/httpServer.d.ts +1 -12
- package/dist/httpServer.d.ts.map +1 -1
- package/dist/httpServer.js +173 -98
- package/dist/httpServer.js.map +1 -1
- package/dist/kernel.d.ts +55 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +366 -31
- package/dist/kernel.js.map +1 -1
- package/dist/lib/agent-services/assumptions.d.ts.map +1 -1
- package/dist/lib/agent-services/assumptions.js +8 -2
- package/dist/lib/agent-services/assumptions.js.map +1 -1
- package/dist/lib/atomicWrite.d.ts +26 -0
- package/dist/lib/atomicWrite.d.ts.map +1 -0
- package/dist/lib/atomicWrite.js +73 -0
- package/dist/lib/atomicWrite.js.map +1 -0
- package/dist/lib/envelope.d.ts +31 -0
- package/dist/lib/envelope.d.ts.map +1 -0
- package/dist/lib/envelope.js +80 -0
- package/dist/lib/envelope.js.map +1 -0
- package/dist/lib/fileLock.d.ts +38 -0
- package/dist/lib/fileLock.d.ts.map +1 -0
- package/dist/lib/fileLock.js +124 -0
- package/dist/lib/fileLock.js.map +1 -0
- package/dist/lib/recordIdAllocator.d.ts +86 -0
- package/dist/lib/recordIdAllocator.d.ts.map +1 -0
- package/dist/lib/recordIdAllocator.js +161 -0
- package/dist/lib/recordIdAllocator.js.map +1 -0
- package/dist/license.js +3 -3
- package/dist/license.js.map +1 -1
- package/dist/projectRegistry.d.ts +65 -3
- package/dist/projectRegistry.d.ts.map +1 -1
- package/dist/projectRegistry.js +233 -33
- package/dist/projectRegistry.js.map +1 -1
- package/dist/runtime/circuitBreaker.d.ts +95 -0
- package/dist/runtime/circuitBreaker.d.ts.map +1 -0
- package/dist/runtime/circuitBreaker.js +178 -0
- package/dist/runtime/circuitBreaker.js.map +1 -0
- package/dist/runtime/ensureRuntime.d.ts +103 -0
- package/dist/runtime/ensureRuntime.d.ts.map +1 -0
- package/dist/runtime/ensureRuntime.js +281 -0
- package/dist/runtime/ensureRuntime.js.map +1 -0
- package/dist/runtime/extensions.d.ts +57 -0
- package/dist/runtime/extensions.d.ts.map +1 -0
- package/dist/runtime/extensions.js +216 -0
- package/dist/runtime/extensions.js.map +1 -0
- package/dist/runtime/localHttp.d.ts +24 -0
- package/dist/runtime/localHttp.d.ts.map +1 -0
- package/dist/runtime/localHttp.js +84 -0
- package/dist/runtime/localHttp.js.map +1 -0
- package/dist/runtime/projectResolution.d.ts +29 -0
- package/dist/runtime/projectResolution.d.ts.map +1 -0
- package/dist/runtime/projectResolution.js +67 -0
- package/dist/runtime/projectResolution.js.map +1 -0
- package/dist/runtime/protocol.d.ts +62 -0
- package/dist/runtime/protocol.d.ts.map +1 -0
- package/dist/runtime/protocol.js +74 -0
- package/dist/runtime/protocol.js.map +1 -0
- package/dist/sentinelIssues.d.ts +26 -2
- package/dist/sentinelIssues.d.ts.map +1 -1
- package/dist/sentinelIssues.js +163 -76
- package/dist/sentinelIssues.js.map +1 -1
- package/dist/server.js +65 -12
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +83 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +445 -0
- package/dist/setup.js.map +1 -0
- package/dist/store/markdown.d.ts +14 -1
- package/dist/store/markdown.d.ts.map +1 -1
- package/dist/store/markdown.js +36 -3
- package/dist/store/markdown.js.map +1 -1
- package/dist/tools/agentic/index.d.ts.map +1 -1
- package/dist/tools/agentic/index.js +28 -2
- package/dist/tools/agentic/index.js.map +1 -1
- package/dist/tools/architect/index.d.ts.map +1 -1
- package/dist/tools/architect/index.js +7 -2
- package/dist/tools/architect/index.js.map +1 -1
- package/dist/tools/conductor/index.d.ts +9 -0
- package/dist/tools/conductor/index.d.ts.map +1 -0
- package/dist/tools/conductor/index.js +242 -0
- package/dist/tools/conductor/index.js.map +1 -0
- package/dist/tools/context.d.ts +10 -5
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +45 -18
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/coordinator/coordinator.d.ts.map +1 -1
- package/dist/tools/coordinator/coordinator.js +48 -19
- package/dist/tools/coordinator/coordinator.js.map +1 -1
- package/dist/tools/crit.d.ts +2 -1
- package/dist/tools/crit.d.ts.map +1 -1
- package/dist/tools/crit.js +19 -4
- package/dist/tools/crit.js.map +1 -1
- package/dist/tools/designer/index.d.ts.map +1 -1
- package/dist/tools/designer/index.js +4 -3
- package/dist/tools/designer/index.js.map +1 -1
- package/dist/tools/designer/lateral-tools.d.ts.map +1 -1
- package/dist/tools/designer/lateral-tools.js +4 -2
- package/dist/tools/designer/lateral-tools.js.map +1 -1
- package/dist/tools/designer.d.ts +2 -1
- package/dist/tools/designer.d.ts.map +1 -1
- package/dist/tools/designer.js +24 -12
- package/dist/tools/designer.js.map +1 -1
- package/dist/tools/dojo.d.ts +3 -2
- package/dist/tools/dojo.d.ts.map +1 -1
- package/dist/tools/dojo.js +64 -22
- package/dist/tools/dojo.js.map +1 -1
- package/dist/tools/dojoPolicy.js +1 -1
- package/dist/tools/dojoPolicy.js.map +1 -1
- package/dist/tools/friction.d.ts.map +1 -1
- package/dist/tools/friction.js +16 -6
- package/dist/tools/friction.js.map +1 -1
- package/dist/tools/guardian.d.ts +29 -7
- package/dist/tools/guardian.d.ts.map +1 -1
- package/dist/tools/guardian.js +90 -75
- package/dist/tools/guardian.js.map +1 -1
- package/dist/tools/hygiene/oracle-hygiene.d.ts.map +1 -1
- package/dist/tools/hygiene/oracle-hygiene.js +8 -2
- package/dist/tools/hygiene/oracle-hygiene.js.map +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +29 -18
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/learnings.d.ts +2 -1
- package/dist/tools/learnings.d.ts.map +1 -1
- package/dist/tools/learnings.js +31 -15
- package/dist/tools/learnings.js.map +1 -1
- package/dist/tools/oracle.d.ts.map +1 -1
- package/dist/tools/oracle.js +19 -6
- package/dist/tools/oracle.js.map +1 -1
- package/dist/tools/policy.d.ts +2 -1
- package/dist/tools/policy.d.ts.map +1 -1
- package/dist/tools/policy.js +28 -9
- package/dist/tools/policy.js.map +1 -1
- package/dist/tools/postoffice/index.d.ts +10 -0
- package/dist/tools/postoffice/index.d.ts.map +1 -0
- package/dist/tools/postoffice/index.js +218 -0
- package/dist/tools/postoffice/index.js.map +1 -0
- package/dist/tools/postoffice.d.ts +78 -0
- package/dist/tools/postoffice.d.ts.map +1 -0
- package/dist/tools/postoffice.js +199 -0
- package/dist/tools/postoffice.js.map +1 -0
- package/dist/tools/provenance.d.ts +7 -0
- package/dist/tools/provenance.d.ts.map +1 -1
- package/dist/tools/provenance.js +42 -16
- package/dist/tools/provenance.js.map +1 -1
- package/dist/tools/registry/index.d.ts.map +1 -1
- package/dist/tools/registry/index.js +41 -12
- package/dist/tools/registry/index.js.map +1 -1
- package/dist/tools/sentinel/index.d.ts +1 -0
- package/dist/tools/sentinel/index.d.ts.map +1 -1
- package/dist/tools/sentinel/index.js +88 -4
- package/dist/tools/sentinel/index.js.map +1 -1
- package/dist/tools/sentinel.d.ts +117 -1
- package/dist/tools/sentinel.d.ts.map +1 -1
- package/dist/tools/sentinel.js +761 -339
- package/dist/tools/sentinel.js.map +1 -1
- package/dist/tools/shared/index.d.ts +2 -1
- package/dist/tools/shared/index.d.ts.map +1 -1
- package/dist/tools/shared/index.js +2 -1
- package/dist/tools/shared/index.js.map +1 -1
- package/dist/tools/shared/response.d.ts +24 -0
- package/dist/tools/shared/response.d.ts.map +1 -1
- package/dist/tools/shared/response.js +61 -7
- package/dist/tools/shared/response.js.map +1 -1
- package/dist/tools/shared/storeRead.d.ts +153 -0
- package/dist/tools/shared/storeRead.d.ts.map +1 -0
- package/dist/tools/shared/storeRead.js +242 -0
- package/dist/tools/shared/storeRead.js.map +1 -0
- package/dist/tools/testSpec.d.ts +3 -2
- package/dist/tools/testSpec.d.ts.map +1 -1
- package/dist/tools/testSpec.js +51 -14
- package/dist/tools/testSpec.js.map +1 -1
- package/dist/tools/vector.d.ts +33 -1
- package/dist/tools/vector.d.ts.map +1 -1
- package/dist/tools/vector.js +31 -5
- package/dist/tools/vector.js.map +1 -1
- package/dist/tools/zoom/index.d.ts +15 -0
- package/dist/tools/zoom/index.d.ts.map +1 -0
- package/dist/tools/zoom/index.js +174 -0
- package/dist/tools/zoom/index.js.map +1 -0
- package/dist/tools/zoom.d.ts +234 -0
- package/dist/tools/zoom.d.ts.map +1 -0
- package/dist/tools/zoom.js +627 -0
- package/dist/tools/zoom.js.map +1 -0
- package/dist/transports/bridge.d.ts +1 -1
- package/dist/transports/bridge.d.ts.map +1 -1
- package/dist/transports/bridge.js +2 -0
- package/dist/transports/bridge.js.map +1 -1
- package/dist/transports/http.d.ts +1 -1
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +3 -1
- package/dist/transports/http.js.map +1 -1
- package/dist/transports/index.d.ts +1 -0
- package/dist/transports/index.d.ts.map +1 -1
- package/dist/transports/index.js +1 -0
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/mcp.d.ts +1 -1
- package/dist/transports/mcp.d.ts.map +1 -1
- package/dist/transports/mcp.js +7 -3
- package/dist/transports/mcp.js.map +1 -1
- package/dist/transports/stdio.d.ts +1 -1
- package/dist/transports/stdio.d.ts.map +1 -1
- package/dist/transports/stdio.js +2 -0
- package/dist/transports/stdio.js.map +1 -1
- package/dist/transports/thinStdio.d.ts +22 -0
- package/dist/transports/thinStdio.d.ts.map +1 -0
- package/dist/transports/thinStdio.js +218 -0
- package/dist/transports/thinStdio.js.map +1 -0
- package/dist/transports/types.d.ts +8 -1
- package/dist/transports/types.d.ts.map +1 -1
- package/dist/types/agent-services.d.ts +7 -0
- package/dist/types/agent-services.d.ts.map +1 -1
- package/package.json +9 -4
- package/templates/com.decibel.daemon.plist +6 -1
- package/dist/tools/deck.d.ts +0 -11
- package/dist/tools/deck.d.ts.map +0 -1
- package/dist/tools/deck.js +0 -1656
- package/dist/tools/deck.js.map +0 -1
- package/dist/tools/mother.d.ts +0 -11
- package/dist/tools/mother.d.ts.map +0 -1
- package/dist/tools/mother.js +0 -503
- package/dist/tools/mother.js.map +0 -1
- package/dist/tools/registry.d.ts +0 -3
- package/dist/tools/registry.d.ts.map +0 -1
- package/dist/tools/registry.js +0 -189
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/senken.d.ts +0 -11
- package/dist/tools/senken.d.ts.map +0 -1
- package/dist/tools/senken.js +0 -482
- package/dist/tools/senken.js.map +0 -1
- package/dist/tools/terminal.d.ts +0 -17
- package/dist/tools/terminal.d.ts.map +0 -1
- package/dist/tools/terminal.js +0 -968
- package/dist/tools/terminal.js.map +0 -1
- package/dist/tools/toolsIndex.d.ts +0 -5
- package/dist/tools/toolsIndex.d.ts.map +0 -1
- package/dist/tools/toolsIndex.js +0 -37
- package/dist/tools/toolsIndex.js.map +0 -1
package/dist/tools/sentinel.js
CHANGED
|
@@ -1,10 +1,42 @@
|
|
|
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';
|
|
13
|
+
/**
|
|
14
|
+
* Sentinel record files exist in two on-disk formats: markdown-with-frontmatter
|
|
15
|
+
* (`.md`) and bare YAML (`.yml`/`.yaml`). Historically every directory scan
|
|
16
|
+
* filtered on `.md` only, so ~530 `.yml` issues across 15 projects were
|
|
17
|
+
* silently omitted from list_issues while read_issue/update_issue resolved
|
|
18
|
+
* them fine. Treat both as first-class.
|
|
19
|
+
*/
|
|
20
|
+
const RECORD_EXT_RE = /\.(md|ya?ml)$/i;
|
|
21
|
+
function isRecordFile(filename) {
|
|
22
|
+
return RECORD_EXT_RE.test(filename);
|
|
23
|
+
}
|
|
24
|
+
function stripRecordExt(filename) {
|
|
25
|
+
return filename.replace(RECORD_EXT_RE, '');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Extract the frontmatter region from a record file. `.md` records delimit it
|
|
29
|
+
* with `---`; bare `.yml` records are frontmatter all the way down, so the
|
|
30
|
+
* whole file is the region.
|
|
31
|
+
*/
|
|
32
|
+
function frontmatterRegion(content) {
|
|
33
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
34
|
+
if (fmMatch)
|
|
35
|
+
return fmMatch[1];
|
|
36
|
+
if (/^---/.test(content.trimStart()))
|
|
37
|
+
return null; // malformed delimiter block
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
8
40
|
/**
|
|
9
41
|
* Quote a scalar for use as a YAML frontmatter value when the string contains
|
|
10
42
|
* characters that would otherwise change parser behaviour. Plain titles like
|
|
@@ -55,104 +87,210 @@ function formatTimestampForFilename(date) {
|
|
|
55
87
|
const iso = date.toISOString();
|
|
56
88
|
return iso.replace(/:/g, '-').replace(/\.\d{3}Z$/, 'Z');
|
|
57
89
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.map((f) => {
|
|
64
|
-
const match = f.match(/^EPIC-(\d+)/);
|
|
65
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
66
|
-
});
|
|
67
|
-
return epicNumbers.length > 0 ? Math.max(...epicNumbers) + 1 : 1;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return 1;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
function formatEpicId(num) {
|
|
74
|
-
return `EPIC-${num.toString().padStart(4, '0')}`;
|
|
75
|
-
}
|
|
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.
|
|
76
95
|
function formatIssueId(num) {
|
|
77
96
|
return `ISS-${num.toString().padStart(4, '0')}`;
|
|
78
97
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
161
|
}
|
|
104
|
-
|
|
105
|
-
//
|
|
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;
|
|
106
178
|
}
|
|
107
179
|
}
|
|
108
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
|
+
}
|
|
109
211
|
catch {
|
|
110
|
-
//
|
|
212
|
+
// Not valid YAML — fall through rather than dropping the record.
|
|
111
213
|
}
|
|
112
|
-
return
|
|
214
|
+
return parseLooseFrontmatter(raw);
|
|
113
215
|
}
|
|
114
216
|
async function parseEpicFile(filePath) {
|
|
115
217
|
try {
|
|
116
218
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const key = line.slice(0, colonIndex).trim();
|
|
125
|
-
let value = line.slice(colonIndex + 1).trim();
|
|
126
|
-
// Handle arrays (simple format: [item1, item2])
|
|
127
|
-
if (value.startsWith('[') && value.endsWith(']')) {
|
|
128
|
-
frontmatter[key] = value
|
|
129
|
-
.slice(1, -1)
|
|
130
|
-
.split(',')
|
|
131
|
-
.map((s) => s.trim().replace(/^"(.*)"$/s, '$1'))
|
|
132
|
-
.filter((s) => s.length > 0);
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
// Strip YAML-style outer quoting. yamlScalar writes values containing
|
|
136
|
-
// '#', ':' etc. as JSON-style double-quoted strings; without this,
|
|
137
|
-
// the naive parser returns the literal "..."-wrapped string.
|
|
138
|
-
if (value.length >= 2) {
|
|
139
|
-
if (value.startsWith('"') && value.endsWith('"')) {
|
|
140
|
-
try {
|
|
141
|
-
value = JSON.parse(value);
|
|
142
|
-
}
|
|
143
|
-
catch { /* keep as-is on bad escape */ }
|
|
144
|
-
}
|
|
145
|
-
else if (value.startsWith("'") && value.endsWith("'")) {
|
|
146
|
-
value = value.slice(1, -1).replace(/''/g, "'");
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
frontmatter[key] = value;
|
|
150
|
-
}
|
|
219
|
+
const hasDelimiters = /^---\r?\n/.test(content);
|
|
220
|
+
// Bare-YAML epics (`.yml`/`.yaml`) have no `---` fence and no markdown
|
|
221
|
+
// body — parse them as YAML outright rather than failing the fence check.
|
|
222
|
+
if (!hasDelimiters) {
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = safeParseYaml(content);
|
|
151
226
|
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
231
|
+
return null;
|
|
232
|
+
const str = (k) => typeof parsed[k] === 'string' ? parsed[k] : '';
|
|
233
|
+
const list = (k) => Array.isArray(parsed[k]) ? parsed[k] : [];
|
|
234
|
+
return {
|
|
235
|
+
id: str('id'),
|
|
236
|
+
title: str('title'),
|
|
237
|
+
summary: str('summary'),
|
|
238
|
+
status: str('status') || 'planned',
|
|
239
|
+
priority: str('priority') || 'medium',
|
|
240
|
+
motivation: list('motivation'),
|
|
241
|
+
outcomes: list('outcomes'),
|
|
242
|
+
acceptance_criteria: list('acceptance_criteria'),
|
|
243
|
+
tags: list('tags'),
|
|
244
|
+
owner: str('owner'),
|
|
245
|
+
squad: str('squad'),
|
|
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: [],
|
|
252
|
+
};
|
|
152
253
|
}
|
|
254
|
+
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
255
|
+
if (!frontmatterMatch)
|
|
256
|
+
return null;
|
|
257
|
+
const frontmatter = parseEpicFrontmatter(frontmatterMatch[1]);
|
|
153
258
|
// Extract sections from body
|
|
154
259
|
const bodyMatch = content.match(/---\n\n([\s\S]*)/);
|
|
155
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
|
+
};
|
|
156
294
|
const extractList = (section) => {
|
|
157
295
|
const regex = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`);
|
|
158
296
|
const match = body.match(regex);
|
|
@@ -176,6 +314,7 @@ async function parseEpicFile(filePath) {
|
|
|
176
314
|
owner: frontmatter.owner || '',
|
|
177
315
|
squad: frontmatter.squad || '',
|
|
178
316
|
created_at: frontmatter.created_at || '',
|
|
317
|
+
notes: extractNotes(),
|
|
179
318
|
};
|
|
180
319
|
}
|
|
181
320
|
catch {
|
|
@@ -189,7 +328,7 @@ async function getAllEpics(projectId) {
|
|
|
189
328
|
try {
|
|
190
329
|
const files = await fs.readdir(epicsDir);
|
|
191
330
|
for (const file of files) {
|
|
192
|
-
if (!file
|
|
331
|
+
if (!isRecordFile(file))
|
|
193
332
|
continue;
|
|
194
333
|
const filePath = path.join(epicsDir, file);
|
|
195
334
|
const epic = await parseEpicFile(filePath);
|
|
@@ -255,108 +394,169 @@ function calculateFuzzyScore(query, text) {
|
|
|
255
394
|
* `epic_id` injection via crafted column-0 lines. The real parser closes both.
|
|
256
395
|
*/
|
|
257
396
|
async function parseIssueFile(filePath) {
|
|
397
|
+
let content;
|
|
258
398
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
399
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
// 1. Whole-file parse. Covers well-formed bare YAML and, via safeParseYaml's
|
|
405
|
+
// multi-doc handling, most markdown-with-frontmatter records.
|
|
406
|
+
try {
|
|
407
|
+
const parsed = safeParseYaml(content);
|
|
408
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
409
|
+
return buildIssueSummary(filePath, content, parsed, false);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// fall through
|
|
414
|
+
}
|
|
415
|
+
// 2. Delimited frontmatter only — the markdown body confused the parser.
|
|
416
|
+
const region = frontmatterRegion(content);
|
|
417
|
+
if (region && region !== content) {
|
|
261
418
|
try {
|
|
262
|
-
parsed = safeParseYaml(
|
|
419
|
+
const parsed = safeParseYaml(region);
|
|
420
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
421
|
+
return buildIssueSummary(filePath, content, parsed, false);
|
|
422
|
+
}
|
|
263
423
|
}
|
|
264
424
|
catch {
|
|
265
|
-
//
|
|
266
|
-
// the multi-doc parser. Last-resort: extract just the frontmatter region
|
|
267
|
-
// manually and parse that.
|
|
268
|
-
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
269
|
-
if (!fmMatch)
|
|
270
|
-
return null;
|
|
271
|
-
try {
|
|
272
|
-
parsed = safeParseYaml(fmMatch[1]);
|
|
273
|
-
}
|
|
274
|
-
catch {
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
425
|
+
// fall through
|
|
277
426
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
427
|
+
}
|
|
428
|
+
// 3. Salvage. close_issue appends a markdown `## Resolution` section to
|
|
429
|
+
// bare-YAML records; the prose beneath it is not valid YAML, so the whole
|
|
430
|
+
// record used to vanish. Parse only the region above the first heading.
|
|
431
|
+
const salvaged = salvageBareYaml(content);
|
|
432
|
+
if (salvaged)
|
|
433
|
+
return buildIssueSummary(filePath, content, salvaged, true);
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Parse only the YAML region above the first markdown heading. Returns null if
|
|
438
|
+
* the record is delimited, has no heading, or the region is still unparseable.
|
|
439
|
+
*/
|
|
440
|
+
function salvageBareYaml(content) {
|
|
441
|
+
if (/^---/.test(content.trimStart()))
|
|
442
|
+
return null;
|
|
443
|
+
const lines = content.split('\n');
|
|
444
|
+
const headingIdx = lines.findIndex((l) => /^#{1,6}\s/.test(l));
|
|
445
|
+
if (headingIdx <= 0)
|
|
446
|
+
return null;
|
|
447
|
+
try {
|
|
448
|
+
const parsed = safeParseYaml(lines.slice(0, headingIdx).join('\n'));
|
|
449
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
450
|
+
return null;
|
|
451
|
+
return parsed;
|
|
299
452
|
}
|
|
300
453
|
catch {
|
|
301
454
|
return null;
|
|
302
455
|
}
|
|
303
456
|
}
|
|
304
|
-
|
|
457
|
+
function buildIssueSummary(filePath, content, parsed, degraded) {
|
|
458
|
+
// Title: first markdown heading wins (legacy markdown-frontmatter format),
|
|
459
|
+
// else explicit `title:` key in YAML, else filename as last-resort.
|
|
460
|
+
const titleMatch = content.match(/^# (.+)$/m);
|
|
461
|
+
const title = titleMatch?.[1] ??
|
|
462
|
+
(typeof parsed.title === 'string' ? parsed.title : undefined) ??
|
|
463
|
+
stripRecordExt(path.basename(filePath));
|
|
464
|
+
// ID: prefer canonical ISS-NNNN form from frontmatter, else filename.
|
|
465
|
+
const fmId = typeof parsed.id === 'string' ? parsed.id.match(/^ISS-\d+$/i)?.[0] : undefined;
|
|
466
|
+
const id = fmId ?? path.basename(filePath);
|
|
467
|
+
const summary = {
|
|
468
|
+
id,
|
|
469
|
+
title,
|
|
470
|
+
severity: parsed.severity || 'low',
|
|
471
|
+
status: (typeof parsed.status === 'string' ? parsed.status : undefined) || 'open',
|
|
472
|
+
epic_id: (typeof parsed.epic_id === 'string' ? parsed.epic_id : undefined) ??
|
|
473
|
+
(typeof parsed.epicId === 'string' ? parsed.epicId : undefined),
|
|
474
|
+
priority: typeof parsed.priority === 'string' ? parsed.priority : undefined,
|
|
475
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
|
|
476
|
+
created_at: typeof parsed.created_at === 'string' ? parsed.created_at : undefined,
|
|
477
|
+
updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : undefined,
|
|
478
|
+
};
|
|
479
|
+
if (degraded)
|
|
480
|
+
summary.degraded = true;
|
|
481
|
+
return summary;
|
|
482
|
+
}
|
|
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) {
|
|
305
498
|
const resolved = resolveProjectPaths(projectId);
|
|
306
499
|
const issuesDir = resolved.subPath('sentinel', 'issues');
|
|
500
|
+
const at = (filename) => ({ filePath: path.join(issuesDir, filename), filename });
|
|
307
501
|
try {
|
|
308
502
|
const files = await fs.readdir(issuesDir);
|
|
309
|
-
//
|
|
503
|
+
// Tier 1: exact filename. A directory cannot hold two of these.
|
|
310
504
|
if (files.includes(issueId)) {
|
|
311
|
-
return
|
|
505
|
+
return [at(issueId)];
|
|
312
506
|
}
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
507
|
+
// Tier 2: filename + record extension. At most one per extension, and the
|
|
508
|
+
// extension order (.md, .yml, .yaml) is the established precedence.
|
|
509
|
+
if (!isRecordFile(issueId)) {
|
|
510
|
+
for (const ext of ['.md', '.yml', '.yaml']) {
|
|
511
|
+
const candidate = `${issueId}${ext}`;
|
|
512
|
+
if (files.includes(candidate)) {
|
|
513
|
+
return [at(candidate)];
|
|
514
|
+
}
|
|
515
|
+
}
|
|
317
516
|
}
|
|
318
517
|
// ISS-NNNN: match filename prefix with word boundary, or frontmatter id
|
|
319
518
|
const issMatch = issueId.match(/^ISS-(\d+)$/i);
|
|
320
519
|
if (issMatch) {
|
|
321
520
|
const padded = `ISS-${issMatch[1].padStart(4, '0')}`.toLowerCase();
|
|
322
|
-
|
|
521
|
+
// Tier 3: filename prefix. THIS is where duplicates collide.
|
|
522
|
+
const prefixHits = files.filter((f) => {
|
|
323
523
|
const lower = f.toLowerCase();
|
|
324
|
-
return lower.startsWith(`${padded}-`) || lower ===
|
|
524
|
+
return lower.startsWith(`${padded}-`) || stripRecordExt(lower) === padded;
|
|
325
525
|
});
|
|
326
|
-
if (
|
|
327
|
-
return
|
|
526
|
+
if (prefixHits.length > 0) {
|
|
527
|
+
return prefixHits.map(at);
|
|
328
528
|
}
|
|
329
|
-
//
|
|
529
|
+
// Tier 4: frontmatter id, for legacy retroactively-stamped issues.
|
|
530
|
+
const fmHits = [];
|
|
330
531
|
for (const f of files) {
|
|
331
|
-
if (!f
|
|
532
|
+
if (!isRecordFile(f))
|
|
332
533
|
continue;
|
|
333
534
|
try {
|
|
334
535
|
const content = await fs.readFile(path.join(issuesDir, f), 'utf-8');
|
|
335
|
-
const
|
|
336
|
-
if (!
|
|
536
|
+
const region = frontmatterRegion(content);
|
|
537
|
+
if (!region)
|
|
337
538
|
continue;
|
|
338
|
-
const idLine =
|
|
539
|
+
const idLine = region.split('\n').find((l) => l.trim().toLowerCase().startsWith('id:'));
|
|
339
540
|
if (!idLine)
|
|
340
541
|
continue;
|
|
341
542
|
const idVal = idLine.slice(idLine.indexOf(':') + 1).trim().toLowerCase();
|
|
342
|
-
if (idVal === padded)
|
|
343
|
-
|
|
344
|
-
}
|
|
543
|
+
if (idVal === padded)
|
|
544
|
+
fmHits.push(f);
|
|
345
545
|
}
|
|
346
546
|
catch {
|
|
347
547
|
// skip unreadable
|
|
348
548
|
}
|
|
349
549
|
}
|
|
550
|
+
if (fmHits.length > 0)
|
|
551
|
+
return fmHits.map(at);
|
|
350
552
|
}
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
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);
|
|
357
557
|
}
|
|
358
558
|
catch {
|
|
359
|
-
return
|
|
559
|
+
return [];
|
|
360
560
|
}
|
|
361
561
|
}
|
|
362
562
|
async function getProjectIssues(projectId) {
|
|
@@ -366,7 +566,7 @@ async function getProjectIssues(projectId) {
|
|
|
366
566
|
try {
|
|
367
567
|
const files = await fs.readdir(issuesDir);
|
|
368
568
|
for (const file of files) {
|
|
369
|
-
if (!file
|
|
569
|
+
if (!isRecordFile(file))
|
|
370
570
|
continue;
|
|
371
571
|
const filePath = path.join(issuesDir, file);
|
|
372
572
|
const issue = await parseIssueFile(filePath);
|
|
@@ -384,7 +584,6 @@ async function getProjectIssues(projectId) {
|
|
|
384
584
|
// Issue Functions
|
|
385
585
|
// ============================================================================
|
|
386
586
|
export async function createIssue(input) {
|
|
387
|
-
// Resolve project paths first - fail fast if no project
|
|
388
587
|
let resolved;
|
|
389
588
|
try {
|
|
390
589
|
resolved = resolveProjectPaths(input.projectId);
|
|
@@ -392,74 +591,119 @@ export async function createIssue(input) {
|
|
|
392
591
|
catch (err) {
|
|
393
592
|
return makeProjectResolutionError('create issue');
|
|
394
593
|
}
|
|
395
|
-
|
|
396
|
-
//
|
|
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.
|
|
397
596
|
if (input.epic_id) {
|
|
398
597
|
const allEpics = await getAllEpics(input.projectId);
|
|
399
|
-
|
|
400
|
-
if (!epicExists) {
|
|
401
|
-
// Return structured error with suggestions
|
|
402
|
-
const suggested = allEpics
|
|
403
|
-
.slice(0, 5)
|
|
404
|
-
.map((e) => ({ id: e.id, title: e.title }));
|
|
598
|
+
if (!allEpics.some((e) => e.id === input.epic_id)) {
|
|
405
599
|
return {
|
|
406
600
|
error: 'EPIC_NOT_FOUND',
|
|
407
601
|
epic_id: input.epic_id,
|
|
408
602
|
message: `Unknown epic_id ${input.epic_id}.`,
|
|
409
|
-
suggested_epics:
|
|
603
|
+
suggested_epics: allEpics.slice(0, 5).map((e) => ({ id: e.id, title: e.title })),
|
|
410
604
|
};
|
|
411
605
|
}
|
|
412
606
|
}
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const bodyLines = [
|
|
437
|
-
`# ${input.title}`,
|
|
438
|
-
'',
|
|
439
|
-
`**Severity:** ${input.severity}`,
|
|
440
|
-
`**Status:** open`,
|
|
441
|
-
];
|
|
442
|
-
if (input.epic_id) {
|
|
443
|
-
bodyLines.push(`**Epic:** ${input.epic_id}`);
|
|
444
|
-
}
|
|
445
|
-
bodyLines.push('', '## Details', '', input.details);
|
|
446
|
-
const body = bodyLines.join('\n');
|
|
447
|
-
const content = `${frontmatter}\n\n${body}\n`;
|
|
448
|
-
await fs.writeFile(filePath, content, 'utf-8');
|
|
449
|
-
log(`Sentinel: Created issue at ${filePath} (project: ${resolved.id})`);
|
|
450
|
-
// Emit provenance event for this creation
|
|
451
|
-
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);
|
|
452
630
|
return {
|
|
453
|
-
id:
|
|
454
|
-
timestamp,
|
|
455
|
-
path:
|
|
631
|
+
id: created.issue.id,
|
|
632
|
+
timestamp: created.issue.created_at ?? new Date().toISOString(),
|
|
633
|
+
path: created.path,
|
|
456
634
|
status: 'open',
|
|
457
635
|
epic_id: input.epic_id,
|
|
458
636
|
location: 'project',
|
|
459
637
|
};
|
|
460
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* Split a bare-YAML record into its YAML region and any trailing markdown.
|
|
641
|
+
* close_issue historically appended a `## Resolution` section to bare-YAML
|
|
642
|
+
* records; that tail is pre-existing corruption we preserve rather than
|
|
643
|
+
* discard, while writing new fields into the YAML region where they belong.
|
|
644
|
+
*/
|
|
645
|
+
function splitBareYamlRecord(content) {
|
|
646
|
+
const lines = content.split('\n');
|
|
647
|
+
const headingIdx = lines.findIndex((l) => /^#{1,6}\s/.test(l));
|
|
648
|
+
if (headingIdx < 0)
|
|
649
|
+
return { yaml: lines, tail: [] };
|
|
650
|
+
return { yaml: lines.slice(0, headingIdx), tail: lines.slice(headingIdx) };
|
|
651
|
+
}
|
|
652
|
+
/** Replace a column-0 scalar key in a YAML region, appending it if absent. */
|
|
653
|
+
function setYamlScalarField(lines, key, value) {
|
|
654
|
+
const re = new RegExp(`^${key}:`);
|
|
655
|
+
const idx = lines.findIndex((l) => re.test(l));
|
|
656
|
+
if (idx >= 0) {
|
|
657
|
+
const out = [...lines];
|
|
658
|
+
out[idx] = `${key}: ${value}`;
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
// Append after the last non-blank line so we don't strand it past a gap.
|
|
662
|
+
const out = [...lines];
|
|
663
|
+
while (out.length > 0 && out[out.length - 1].trim() === '')
|
|
664
|
+
out.pop();
|
|
665
|
+
out.push(`${key}: ${value}`);
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
/** Replace a column-0 block-scalar key (and its indented continuation). */
|
|
669
|
+
function setYamlBlockField(lines, key, value) {
|
|
670
|
+
const re = new RegExp(`^${key}:`);
|
|
671
|
+
const out = [];
|
|
672
|
+
let i = 0;
|
|
673
|
+
while (i < lines.length) {
|
|
674
|
+
if (re.test(lines[i])) {
|
|
675
|
+
i++;
|
|
676
|
+
// Consume the indented continuation block, including interior blank
|
|
677
|
+
// lines, but stop before the next column-0 key.
|
|
678
|
+
while (i < lines.length) {
|
|
679
|
+
if (lines[i].trim() === '') {
|
|
680
|
+
let j = i;
|
|
681
|
+
while (j < lines.length && lines[j].trim() === '')
|
|
682
|
+
j++;
|
|
683
|
+
if (j >= lines.length || /^\S/.test(lines[j]))
|
|
684
|
+
break;
|
|
685
|
+
i = j;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (/^\s/.test(lines[i])) {
|
|
689
|
+
i++;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
out.push(lines[i]);
|
|
697
|
+
i++;
|
|
698
|
+
}
|
|
699
|
+
while (out.length > 0 && out[out.length - 1].trim() === '')
|
|
700
|
+
out.pop();
|
|
701
|
+
out.push(`${key}: |-`);
|
|
702
|
+
for (const l of value.split('\n'))
|
|
703
|
+
out.push(` ${l}`);
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
461
706
|
export async function closeIssue(input) {
|
|
462
|
-
// Resolve project paths first
|
|
463
707
|
let resolved;
|
|
464
708
|
try {
|
|
465
709
|
resolved = resolveProjectPaths(input.projectId);
|
|
@@ -467,64 +711,59 @@ export async function closeIssue(input) {
|
|
|
467
711
|
catch (err) {
|
|
468
712
|
return makeProjectResolutionError('close issue');
|
|
469
713
|
}
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
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}`);
|
|
473
724
|
return {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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,
|
|
479
730
|
};
|
|
480
731
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
else {
|
|
493
|
-
updatedContent = updatedContent.replace(/closed_at: .*/, `closed_at: ${closedAt}`);
|
|
494
|
-
}
|
|
495
|
-
// Update status in body
|
|
496
|
-
updatedContent = updatedContent.replace(/\*\*Status:\*\* \w+/, `**Status:** ${newStatus}`);
|
|
497
|
-
// Add resolution section if provided
|
|
498
|
-
if (input.resolution) {
|
|
499
|
-
if (updatedContent.includes('## Resolution')) {
|
|
500
|
-
// Replace existing resolution
|
|
501
|
-
updatedContent = updatedContent.replace(/## Resolution\n\n[\s\S]*?(?=\n## |$)/, `## Resolution\n\n${input.resolution}\n`);
|
|
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
|
+
};
|
|
502
743
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
+
};
|
|
506
753
|
}
|
|
754
|
+
throw err;
|
|
507
755
|
}
|
|
508
|
-
await fs.writeFile(filePath, updatedContent, 'utf-8');
|
|
509
|
-
log(`Sentinel: Closed issue at ${filePath}`);
|
|
510
|
-
// Prefer ISS-NNNN id from filename or frontmatter for the return value
|
|
511
|
-
const filenamePrefix = filename.match(/^(ISS-\d+)/i)?.[1];
|
|
512
|
-
const fmIdMatch = updatedContent.match(/^---\n([\s\S]*?)\n---/);
|
|
513
|
-
const fmId = fmIdMatch?.[1]
|
|
514
|
-
.split('\n')
|
|
515
|
-
.find((l) => l.trim().toLowerCase().startsWith('id:'))
|
|
516
|
-
?.split(':')[1]
|
|
517
|
-
?.trim()
|
|
518
|
-
?.match(/^ISS-\d+$/i)?.[0];
|
|
519
|
-
const returnedId = filenamePrefix ?? fmId ?? filename;
|
|
520
|
-
return {
|
|
521
|
-
id: returnedId,
|
|
522
|
-
path: filePath,
|
|
523
|
-
status: newStatus,
|
|
524
|
-
closed_at: closedAt,
|
|
525
|
-
resolution: input.resolution,
|
|
526
|
-
};
|
|
527
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
|
+
*/
|
|
528
767
|
export async function listRepoIssues(input) {
|
|
529
768
|
let resolved;
|
|
530
769
|
try {
|
|
@@ -533,29 +772,52 @@ export async function listRepoIssues(input) {
|
|
|
533
772
|
catch (err) {
|
|
534
773
|
return makeProjectResolutionError('list issues');
|
|
535
774
|
}
|
|
536
|
-
const
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
if (input.status && issue.status !== input.status)
|
|
548
|
-
continue;
|
|
549
|
-
issues.push(issue);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
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));
|
|
781
|
+
issues.sort((a, b) => b.id.localeCompare(a.id));
|
|
782
|
+
const out = { issues };
|
|
783
|
+
if (integrity.malformed.length > 0) {
|
|
784
|
+
out.malformed = integrity.malformed.length;
|
|
785
|
+
out.malformed_files = integrity.malformed;
|
|
552
786
|
}
|
|
553
|
-
|
|
554
|
-
|
|
787
|
+
if (integrity.degraded.length > 0) {
|
|
788
|
+
out.degraded = integrity.degraded.length;
|
|
789
|
+
out.degraded_files = integrity.degraded.map((d) => d.filename);
|
|
555
790
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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);
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
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;
|
|
559
821
|
}
|
|
560
822
|
// ============================================================================
|
|
561
823
|
// Epic Functions
|
|
@@ -572,55 +834,57 @@ export async function logEpic(input) {
|
|
|
572
834
|
const now = new Date();
|
|
573
835
|
const timestamp = now.toISOString();
|
|
574
836
|
ensureDir(epicsDir);
|
|
575
|
-
const epicNum = await getNextEpicNumber(epicsDir);
|
|
576
|
-
const epicId = formatEpicId(epicNum);
|
|
577
837
|
const slug = slugify(input.title);
|
|
578
|
-
const filename = `${epicId}-${slug}.md`;
|
|
579
|
-
const filePath = path.join(epicsDir, filename);
|
|
580
|
-
validateWritePath(filePath, resolved);
|
|
581
838
|
const priority = input.priority || 'medium';
|
|
582
839
|
const tags = input.tags || [];
|
|
583
840
|
const owner = input.owner || '';
|
|
584
841
|
const squad = input.squad || '';
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
sections
|
|
606
|
-
|
|
607
|
-
|
|
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
|
+
}
|
|
608
869
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
870
|
+
if (input.outcomes && input.outcomes.length > 0) {
|
|
871
|
+
sections.push('', '## Outcomes', '');
|
|
872
|
+
for (const item of input.outcomes) {
|
|
873
|
+
sections.push(`- ${item}`);
|
|
874
|
+
}
|
|
614
875
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
+
}
|
|
620
881
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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) });
|
|
624
888
|
log(`Sentinel: Created epic at ${filePath} (project: ${resolved.id})`);
|
|
625
889
|
// Emit provenance event for this creation
|
|
626
890
|
await emitCreateProvenance(`sentinel:epic:${epicId}`, content, `Created epic: ${input.title}`, input.projectId);
|
|
@@ -641,39 +905,31 @@ export async function listEpics(input) {
|
|
|
641
905
|
}
|
|
642
906
|
const epicsDir = resolved.subPath('sentinel', 'epics');
|
|
643
907
|
const epics = [];
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (input.
|
|
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)))
|
|
657
921
|
continue;
|
|
658
|
-
if (input.tags && input.tags.length > 0) {
|
|
659
|
-
const hasTag = input.tags.some((t) => epic.tags.includes(t));
|
|
660
|
-
if (!hasTag)
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
epics.push({
|
|
664
|
-
id: epic.id,
|
|
665
|
-
title: epic.title,
|
|
666
|
-
status: epic.status,
|
|
667
|
-
priority: epic.priority,
|
|
668
|
-
});
|
|
669
922
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
923
|
+
epics.push({
|
|
924
|
+
id: epic.id,
|
|
925
|
+
title: epic.title,
|
|
926
|
+
status: epic.status,
|
|
927
|
+
priority: epic.priority,
|
|
928
|
+
});
|
|
673
929
|
}
|
|
674
930
|
// Sort by ID (newest first based on number)
|
|
675
931
|
epics.sort((a, b) => b.id.localeCompare(a.id));
|
|
676
|
-
return { epics };
|
|
932
|
+
return { epics, ...storeMeta(read) };
|
|
677
933
|
}
|
|
678
934
|
export async function getEpic(input) {
|
|
679
935
|
let resolved;
|
|
@@ -701,6 +957,172 @@ export async function getEpic(input) {
|
|
|
701
957
|
return { epic: null, error: `Epic not found: ${input.epic_id}` };
|
|
702
958
|
}
|
|
703
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
|
+
}
|
|
704
1126
|
export async function getEpicIssues(input) {
|
|
705
1127
|
let resolved;
|
|
706
1128
|
try {
|
|
@@ -714,7 +1136,7 @@ export async function getEpicIssues(input) {
|
|
|
714
1136
|
try {
|
|
715
1137
|
const issueFiles = await fs.readdir(issuesDir);
|
|
716
1138
|
for (const file of issueFiles) {
|
|
717
|
-
if (!file
|
|
1139
|
+
if (!isRecordFile(file))
|
|
718
1140
|
continue;
|
|
719
1141
|
const filePath = path.join(issuesDir, file);
|
|
720
1142
|
// Parse first, then filter on the parsed epic_id field. Previously this
|