@decibelsystems/tools 2.2.0-beta.0 → 3.1.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 +226 -0
- package/dist/agentCommands.d.ts +0 -1
- package/dist/agentCommands.d.ts.map +1 -1
- package/dist/agentCommands.js +19 -8
- package/dist/agentCommands.js.map +1 -1
- package/dist/agentPresence.d.ts +82 -0
- package/dist/agentPresence.d.ts.map +1 -1
- package/dist/agentPresence.js +194 -26
- 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 +27 -3
- package/dist/architectAdrs.d.ts.map +1 -1
- package/dist/architectAdrs.js +92 -4
- package/dist/architectAdrs.js.map +1 -1
- package/dist/artifacts.d.ts +60 -0
- package/dist/artifacts.d.ts.map +1 -0
- package/dist/artifacts.js +208 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/asks.d.ts +93 -0
- package/dist/asks.d.ts.map +1 -0
- package/dist/asks.js +383 -0
- package/dist/asks.js.map +1 -0
- package/dist/chat.d.ts +47 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +288 -0
- package/dist/chat.js.map +1 -0
- 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 +33 -3
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +225 -52
- package/dist/daemon.js.map +1 -1
- package/dist/daemonConfig.d.ts +46 -0
- package/dist/daemonConfig.d.ts.map +1 -1
- package/dist/daemonConfig.js +57 -12
- 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 +81 -0
- package/dist/domain/issue.d.ts.map +1 -0
- package/dist/domain/issue.js +106 -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 +308 -0
- package/dist/domain/issueCodec.js.map +1 -0
- package/dist/domain/issueRepository.d.ts +124 -0
- package/dist/domain/issueRepository.d.ts.map +1 -0
- package/dist/domain/issueRepository.js +296 -0
- package/dist/domain/issueRepository.js.map +1 -0
- package/dist/domain/uid.d.ts +16 -0
- package/dist/domain/uid.d.ts.map +1 -0
- package/dist/domain/uid.js +43 -0
- package/dist/domain/uid.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 +140 -116
- 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/hq/defineAgent.d.ts.map +1 -1
- package/dist/hq/defineAgent.js +7 -11
- package/dist/hq/defineAgent.js.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 +298 -144
- package/dist/httpServer.js.map +1 -1
- package/dist/identity.d.ts +123 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +312 -0
- package/dist/identity.js.map +1 -0
- package/dist/kernel.d.ts +46 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +334 -26
- package/dist/kernel.js.map +1 -1
- package/dist/killSwitch.d.ts +117 -0
- package/dist/killSwitch.d.ts.map +1 -0
- package/dist/killSwitch.js +161 -0
- package/dist/killSwitch.js.map +1 -0
- 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 +49 -0
- package/dist/lib/atomicWrite.d.ts.map +1 -0
- package/dist/lib/atomicWrite.js +157 -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 +102 -0
- package/dist/lib/recordIdAllocator.d.ts.map +1 -0
- package/dist/lib/recordIdAllocator.js +179 -0
- package/dist/lib/recordIdAllocator.js.map +1 -0
- package/dist/orgId.d.ts +23 -0
- package/dist/orgId.d.ts.map +1 -0
- package/dist/orgId.js +73 -0
- package/dist/orgId.js.map +1 -0
- package/dist/pairCli.d.ts +2 -0
- package/dist/pairCli.d.ts.map +1 -0
- package/dist/pairCli.js +97 -0
- package/dist/pairCli.js.map +1 -0
- package/dist/peerBroker.d.ts +40 -0
- package/dist/peerBroker.d.ts.map +1 -0
- package/dist/peerBroker.js +70 -0
- package/dist/peerBroker.js.map +1 -0
- package/dist/peerBrokerSupervisor.d.ts +21 -0
- package/dist/peerBrokerSupervisor.d.ts.map +1 -0
- package/dist/peerBrokerSupervisor.js +131 -0
- package/dist/peerBrokerSupervisor.js.map +1 -0
- package/dist/projectPaths.d.ts.map +1 -1
- package/dist/projectPaths.js +1 -19
- package/dist/projectPaths.js.map +1 -1
- package/dist/projectRegistry.d.ts +22 -0
- package/dist/projectRegistry.d.ts.map +1 -1
- package/dist/projectRegistry.js +69 -2
- package/dist/projectRegistry.js.map +1 -1
- package/dist/pty/boundary.d.ts +39 -0
- package/dist/pty/boundary.d.ts.map +1 -0
- package/dist/pty/boundary.js +100 -0
- package/dist/pty/boundary.js.map +1 -0
- package/dist/pty/errors.d.ts +6 -0
- package/dist/pty/errors.d.ts.map +1 -0
- package/dist/pty/errors.js +12 -0
- package/dist/pty/errors.js.map +1 -0
- package/dist/pty/fleet.d.ts +159 -0
- package/dist/pty/fleet.d.ts.map +1 -0
- package/dist/pty/fleet.js +543 -0
- package/dist/pty/fleet.js.map +1 -0
- package/dist/pty/launchProfile.d.ts +45 -0
- package/dist/pty/launchProfile.d.ts.map +1 -0
- package/dist/pty/launchProfile.js +149 -0
- package/dist/pty/launchProfile.js.map +1 -0
- package/dist/pty/openTerminal.d.ts +48 -0
- package/dist/pty/openTerminal.d.ts.map +1 -0
- package/dist/pty/openTerminal.js +185 -0
- package/dist/pty/openTerminal.js.map +1 -0
- package/dist/pty/pairDialog.d.ts +16 -0
- package/dist/pty/pairDialog.d.ts.map +1 -0
- package/dist/pty/pairDialog.js +121 -0
- package/dist/pty/pairDialog.js.map +1 -0
- package/dist/pty/pairing.d.ts +86 -0
- package/dist/pty/pairing.d.ts.map +1 -0
- package/dist/pty/pairing.js +351 -0
- package/dist/pty/pairing.js.map +1 -0
- package/dist/pty/routes.d.ts +49 -0
- package/dist/pty/routes.d.ts.map +1 -0
- package/dist/pty/routes.js +624 -0
- package/dist/pty/routes.js.map +1 -0
- package/dist/pty/sessions.d.ts +167 -0
- package/dist/pty/sessions.d.ts.map +1 -0
- package/dist/pty/sessions.js +513 -0
- package/dist/pty/sessions.js.map +1 -0
- package/dist/pty/tmux.d.ts +109 -0
- package/dist/pty/tmux.d.ts.map +1 -0
- package/dist/pty/tmux.js +271 -0
- package/dist/pty/tmux.js.map +1 -0
- package/dist/roster.d.ts +37 -0
- package/dist/roster.d.ts.map +1 -0
- package/dist/roster.js +126 -0
- package/dist/roster.js.map +1 -0
- 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/daemonMeta.d.ts +53 -0
- package/dist/runtime/daemonMeta.d.ts.map +1 -0
- package/dist/runtime/daemonMeta.js +121 -0
- package/dist/runtime/daemonMeta.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 +276 -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 +42 -2
- package/dist/sentinelIssues.d.ts.map +1 -1
- package/dist/sentinelIssues.js +167 -65
- package/dist/sentinelIssues.js.map +1 -1
- package/dist/server.js +123 -15
- package/dist/server.js.map +1 -1
- package/dist/toolConfig.d.ts.map +1 -1
- package/dist/toolConfig.js +12 -0
- package/dist/toolConfig.js.map +1 -1
- package/dist/tools/agentic/agentQueue.d.ts.map +1 -1
- package/dist/tools/agentic/agentQueue.js +4 -0
- package/dist/tools/agentic/agentQueue.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 +1 -0
- package/dist/tools/architect/index.d.ts.map +1 -1
- package/dist/tools/architect/index.js +52 -3
- package/dist/tools/architect/index.js.map +1 -1
- package/dist/tools/architect.d.ts.map +1 -1
- package/dist/tools/architect.js +59 -8
- package/dist/tools/architect.js.map +1 -1
- package/dist/tools/artifacts.d.ts +5 -0
- package/dist/tools/artifacts.d.ts.map +1 -0
- package/dist/tools/artifacts.js +89 -0
- package/dist/tools/artifacts.js.map +1 -0
- package/dist/tools/asks.d.ts +6 -0
- package/dist/tools/asks.d.ts.map +1 -0
- package/dist/tools/asks.js +104 -0
- package/dist/tools/asks.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/coordinator/index.d.ts +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +42 -2
- package/dist/tools/coordinator/index.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/data-inspector.d.ts +1 -1
- package/dist/tools/data-inspector.d.ts.map +1 -1
- package/dist/tools/data-inspector.js +1 -1
- package/dist/tools/data-inspector.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/index.d.ts.map +1 -1
- package/dist/tools/dojo/index.js +52 -3
- package/dist/tools/dojo/index.js.map +1 -1
- package/dist/tools/dojo.d.ts +65 -3
- package/dist/tools/dojo.d.ts.map +1 -1
- package/dist/tools/dojo.js +148 -23
- package/dist/tools/dojo.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 +7 -0
- package/dist/tools/guardian.d.ts.map +1 -1
- package/dist/tools/guardian.js +152 -11
- 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 +33 -18
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/killswitch/index.d.ts +13 -0
- package/dist/tools/killswitch/index.d.ts.map +1 -0
- package/dist/tools/killswitch/index.js +138 -0
- package/dist/tools/killswitch/index.js.map +1 -0
- 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/index.d.ts.map +1 -1
- package/dist/tools/oracle/index.js +8 -0
- package/dist/tools/oracle/index.js.map +1 -1
- package/dist/tools/oracle.d.ts +7 -0
- package/dist/tools/oracle.d.ts.map +1 -1
- package/dist/tools/oracle.js +41 -12
- 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 +18 -2
- package/dist/tools/provenance.d.ts.map +1 -1
- package/dist/tools/provenance.js +68 -40
- package/dist/tools/provenance.js.map +1 -1
- package/dist/tools/registry/index.d.ts.map +1 -1
- package/dist/tools/registry/index.js +24 -0
- 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 +142 -6
- package/dist/tools/sentinel/index.js.map +1 -1
- package/dist/tools/sentinel.d.ts +152 -1
- package/dist/tools/sentinel.d.ts.map +1 -1
- package/dist/tools/sentinel.js +609 -328
- 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/runTracker.d.ts +2 -0
- package/dist/tools/shared/runTracker.d.ts.map +1 -1
- package/dist/tools/shared/runTracker.js +38 -0
- package/dist/tools/shared/runTracker.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/swarm.d.ts +3 -1
- package/dist/tools/swarm.d.ts.map +1 -1
- package/dist/tools/swarm.js +6 -12
- package/dist/tools/swarm.js.map +1 -1
- 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/voice/index.d.ts +1 -0
- package/dist/tools/voice/index.d.ts.map +1 -1
- package/dist/tools/voice/index.js +33 -2
- package/dist/tools/voice/index.js.map +1 -1
- package/dist/tools/voice.d.ts +48 -1
- package/dist/tools/voice.d.ts.map +1 -1
- package/dist/tools/voice.js +63 -3
- package/dist/tools/voice.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 +4 -1
- 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 +9 -4
- 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/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +23 -0
- package/dist/version.js.map +1 -0
- package/package.json +20 -4
- package/templates/AGENT.md +3 -0
- package/templates/CLAUDE-decibel.md +11 -0
- 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,17 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
+
import { resolveScope, inScope } from '../roster.js';
|
|
2
3
|
import path from 'path';
|
|
4
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
3
5
|
import { log } from '../config.js';
|
|
4
6
|
import { ensureDir } from '../dataRoot.js';
|
|
5
7
|
import { resolveProjectPaths, validateWritePath } from '../projectRegistry.js';
|
|
6
8
|
import { emitCreateProvenance } from './provenance.js';
|
|
9
|
+
import { assertActor, assertTeam } from '../identity.js';
|
|
7
10
|
import { safeParseYaml } from '../sentinelIssues.js';
|
|
11
|
+
import { readStoreDir, storeMeta } from './shared/storeRead.js';
|
|
12
|
+
import { FsIssueRepository, AmbiguousIssueIdError as RepoAmbiguousIssueIdError, IssueNotFoundError as RepoIssueNotFoundError, } from '../domain/issueRepository.js';
|
|
13
|
+
import { writeFileAtomic } from '../lib/atomicWrite.js';
|
|
14
|
+
import { allocateAndWriteEpic } from '../lib/recordIdAllocator.js';
|
|
8
15
|
/**
|
|
9
16
|
* Sentinel record files exist in two on-disk formats: markdown-with-frontmatter
|
|
10
17
|
* (`.md`) and bare YAML (`.yml`/`.yaml`). Historically every directory scan
|
|
@@ -82,61 +89,131 @@ function formatTimestampForFilename(date) {
|
|
|
82
89
|
const iso = date.toISOString();
|
|
83
90
|
return iso.replace(/:/g, '-').replace(/\.\d{3}Z$/, 'Z');
|
|
84
91
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
}
|
|
92
|
+
// getNextEpicNumber/formatEpicId lived here and were the epic half of the
|
|
93
|
+
// duplicate-id race: they read the directory for the current maximum, and the
|
|
94
|
+
// write happened ~20 awaits later with nothing holding the two together.
|
|
95
|
+
// Allocation now runs under the same cross-process lock as issues — see
|
|
96
|
+
// allocateAndWriteEpic in src/lib/recordIdAllocator.ts.
|
|
103
97
|
function formatIssueId(num) {
|
|
104
98
|
return `ISS-${num.toString().padStart(4, '0')}`;
|
|
105
99
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
100
|
+
/**
|
|
101
|
+
* The identity a writer would collide on: the padded ISS-NNNN from the
|
|
102
|
+
* filename, else from frontmatter, else the filename itself (which is unique
|
|
103
|
+
* by construction). Mirrors the tier-3/tier-4 matching in findIssueCandidates
|
|
104
|
+
* so the collision report and the write refusal agree on what "same id" means.
|
|
105
|
+
*/
|
|
106
|
+
function canonicalIssueKey(filename, summaryId) {
|
|
107
|
+
const fromName = filename.match(/^ISS-(\d+)/i);
|
|
108
|
+
if (fromName)
|
|
109
|
+
return `ISS-${fromName[1].padStart(4, '0')}`.toUpperCase();
|
|
110
|
+
const fromFrontmatter = summaryId.match(/^ISS-(\d+)$/i);
|
|
111
|
+
if (fromFrontmatter)
|
|
112
|
+
return `ISS-${fromFrontmatter[1].padStart(4, '0')}`.toUpperCase();
|
|
113
|
+
return filename;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Coerce a parsed YAML mapping into the flat shape the epic reader expects.
|
|
117
|
+
* Scalars become strings, sequences become string arrays, absent stays absent.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeFrontmatter(parsed) {
|
|
120
|
+
const out = {};
|
|
121
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
122
|
+
if (value === null || value === undefined) {
|
|
123
|
+
out[key] = '';
|
|
124
|
+
}
|
|
125
|
+
else if (Array.isArray(value)) {
|
|
126
|
+
out[key] = value.map((v) => (typeof v === 'string' ? v : String(v)));
|
|
127
|
+
}
|
|
128
|
+
else if (typeof value === 'string') {
|
|
129
|
+
out[key] = value;
|
|
130
|
+
}
|
|
131
|
+
else if (value instanceof Date) {
|
|
132
|
+
out[key] = value.toISOString();
|
|
133
|
+
}
|
|
134
|
+
else if (typeof value === 'object') {
|
|
135
|
+
// A nested mapping has no representation in the flat epic model. Keep it
|
|
136
|
+
// legible rather than emitting "[object Object]".
|
|
137
|
+
out[key] = JSON.stringify(value);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
out[key] = String(value);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The pre-YAML reader: split each line on its first colon. Retained ONLY as a
|
|
147
|
+
* fallback for records YAML rejects — see parseEpicFrontmatter.
|
|
148
|
+
*/
|
|
149
|
+
function parseLooseFrontmatter(raw) {
|
|
150
|
+
const frontmatter = {};
|
|
151
|
+
for (const line of raw.split('\n')) {
|
|
152
|
+
const colonIndex = line.indexOf(':');
|
|
153
|
+
if (colonIndex > 0) {
|
|
154
|
+
const key = line.slice(0, colonIndex).trim();
|
|
155
|
+
let value = line.slice(colonIndex + 1).trim();
|
|
156
|
+
// Handle arrays (simple format: [item1, item2])
|
|
157
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
158
|
+
frontmatter[key] = value
|
|
159
|
+
.slice(1, -1)
|
|
160
|
+
.split(',')
|
|
161
|
+
.map((s) => s.trim().replace(/^"(.*)"$/s, '$1'))
|
|
162
|
+
.filter((s) => s.length > 0);
|
|
130
163
|
}
|
|
131
|
-
|
|
132
|
-
//
|
|
164
|
+
else {
|
|
165
|
+
// Strip YAML-style outer quoting. yamlScalar writes values containing
|
|
166
|
+
// '#', ':' etc. as JSON-style double-quoted strings; without this,
|
|
167
|
+
// the naive parser returns the literal "..."-wrapped string.
|
|
168
|
+
if (value.length >= 2) {
|
|
169
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
170
|
+
try {
|
|
171
|
+
value = JSON.parse(value);
|
|
172
|
+
}
|
|
173
|
+
catch { /* keep as-is on bad escape */ }
|
|
174
|
+
}
|
|
175
|
+
else if (value.startsWith("'") && value.endsWith("'")) {
|
|
176
|
+
value = value.slice(1, -1).replace(/''/g, "'");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
frontmatter[key] = value;
|
|
133
180
|
}
|
|
134
181
|
}
|
|
135
182
|
}
|
|
183
|
+
return frontmatter;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Parse epic frontmatter, YAML first.
|
|
187
|
+
*
|
|
188
|
+
* The fenced (`.md`) branch used to hand-roll `key: value` splitting while the
|
|
189
|
+
* bare-YAML branch six lines above used a real parser. Splitting on the first
|
|
190
|
+
* colon reports a block-scalar INDICATOR as the value — `summary: |-` read back
|
|
191
|
+
* as the literal string "|-", losing the whole summary — and then treats every
|
|
192
|
+
* indented prose line containing a colon as its own key. On EPIC-0038 that
|
|
193
|
+
* produced 11 invented keys with names like "STILL LIVE" and "degraded".
|
|
194
|
+
*
|
|
195
|
+
* YAML cannot simply replace it. 7 of 38 epic records on disk have frontmatter
|
|
196
|
+
* a real parser rejects: unquoted values containing ": ", emitted by a write
|
|
197
|
+
* path that does not quote (`title: Epic: Special Characters!`). Parsing those
|
|
198
|
+
* strictly would return null and drop them from list_epics entirely — a worse
|
|
199
|
+
* defect than the one being fixed. So YAML when the record is valid, the loose
|
|
200
|
+
* reader when it is not.
|
|
201
|
+
*
|
|
202
|
+
* The loose path is a compatibility shim for existing damage, not a supported
|
|
203
|
+
* format. It is removable once the write side stops emitting unquoted scalars
|
|
204
|
+
* (EPIC-0038 Phase 1e) and the affected records are repaired.
|
|
205
|
+
*/
|
|
206
|
+
function parseEpicFrontmatter(raw) {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = safeParseYaml(raw);
|
|
209
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
210
|
+
return normalizeFrontmatter(parsed);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
136
213
|
catch {
|
|
137
|
-
//
|
|
214
|
+
// Not valid YAML — fall through rather than dropping the record.
|
|
138
215
|
}
|
|
139
|
-
return
|
|
216
|
+
return parseLooseFrontmatter(raw);
|
|
140
217
|
}
|
|
141
218
|
async function parseEpicFile(filePath) {
|
|
142
219
|
try {
|
|
@@ -168,48 +245,60 @@ async function parseEpicFile(filePath) {
|
|
|
168
245
|
tags: list('tags'),
|
|
169
246
|
owner: str('owner'),
|
|
170
247
|
squad: str('squad'),
|
|
248
|
+
// EPIC-0040, same alias rule as the markdown reader. Bare-YAML epics
|
|
249
|
+
// are the OLDER format, so they are the ones most likely to carry only
|
|
250
|
+
// owner/squad — leaving them out here would make the new fields look
|
|
251
|
+
// empty for exactly the records most likely to have a value.
|
|
252
|
+
assignee: str('assignee') || str('owner'),
|
|
253
|
+
team: str('team') || str('squad'),
|
|
171
254
|
created_at: str('created_at'),
|
|
255
|
+
// Bare-YAML epics have no markdown body, so there is nowhere for a
|
|
256
|
+
// `## Note (…)` section to live. Empty, not absent — callers should
|
|
257
|
+
// not have to distinguish "no notes" from "this format cannot hold
|
|
258
|
+
// notes".
|
|
259
|
+
notes: [],
|
|
172
260
|
};
|
|
173
261
|
}
|
|
174
|
-
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
262
|
+
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
175
263
|
if (!frontmatterMatch)
|
|
176
264
|
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
|
-
}
|
|
265
|
+
const frontmatter = parseEpicFrontmatter(frontmatterMatch[1]);
|
|
210
266
|
// Extract sections from body
|
|
211
267
|
const bodyMatch = content.match(/---\n\n([\s\S]*)/);
|
|
212
268
|
const body = bodyMatch ? bodyMatch[1] : '';
|
|
269
|
+
/**
|
|
270
|
+
* Notes are `## Note (<iso>)` sections appended by update_epic, so the
|
|
271
|
+
* reader is written against that exact shape rather than against any
|
|
272
|
+
* heading that looks note-ish.
|
|
273
|
+
*
|
|
274
|
+
* A note runs to the NEXT `## Note (…)` heading, not to the next `##`.
|
|
275
|
+
* That distinction is the whole bug: a note whose own text opens with a
|
|
276
|
+
* markdown heading — `## Resolution`, the commonest thing anyone writes in
|
|
277
|
+
* one — made the terminator match at offset zero, so the section came back
|
|
278
|
+
* empty and `if (text)` dropped it. read_epic then returned `notes: []` for
|
|
279
|
+
* an epic whose file plainly held the note, and update_epic had reported
|
|
280
|
+
* success, which is why it survived #72.
|
|
281
|
+
*
|
|
282
|
+
* Ending at the next NOTE heading is safe because notes are only ever
|
|
283
|
+
* appended after every other section: log_epic writes Summary, Motivation,
|
|
284
|
+
* Outcomes and Acceptance Criteria before any note can exist, and the one
|
|
285
|
+
* writer that could append a section later (the summary fallback below)
|
|
286
|
+
* now inserts ahead of the notes for this reason.
|
|
287
|
+
*/
|
|
288
|
+
const extractNotes = () => {
|
|
289
|
+
const out = [];
|
|
290
|
+
const re = /^##\s+Note\s*\(([^)]*)\)\s*$/gm;
|
|
291
|
+
let m;
|
|
292
|
+
while ((m = re.exec(body)) !== null) {
|
|
293
|
+
const from = m.index + m[0].length;
|
|
294
|
+
const rest = body.slice(from);
|
|
295
|
+
const next = rest.search(/^##\s+Note\s*\([^)]*\)\s*$/m);
|
|
296
|
+
const text = (next === -1 ? rest : rest.slice(0, next)).trim();
|
|
297
|
+
if (text)
|
|
298
|
+
out.push({ at: m[1].trim(), text });
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
};
|
|
213
302
|
const extractList = (section) => {
|
|
214
303
|
const regex = new RegExp(`## ${section}\\n([\\s\\S]*?)(?=\\n## |$)`);
|
|
215
304
|
const match = body.match(regex);
|
|
@@ -232,7 +321,13 @@ async function parseEpicFile(filePath) {
|
|
|
232
321
|
tags: frontmatter.tags || [],
|
|
233
322
|
owner: frontmatter.owner || '',
|
|
234
323
|
squad: frontmatter.squad || '',
|
|
324
|
+
// EPIC-0040. The old fields are read as aliases when the new ones are
|
|
325
|
+
// absent, so an epic written before this still reports an assignee and a
|
|
326
|
+
// team. Nothing is rewritten on disk to make that true.
|
|
327
|
+
assignee: frontmatter.assignee || frontmatter.owner || '',
|
|
328
|
+
team: frontmatter.team || frontmatter.squad || '',
|
|
235
329
|
created_at: frontmatter.created_at || '',
|
|
330
|
+
notes: extractNotes(),
|
|
236
331
|
};
|
|
237
332
|
}
|
|
238
333
|
catch {
|
|
@@ -398,21 +493,37 @@ function buildIssueSummary(filePath, content, parsed, degraded) {
|
|
|
398
493
|
summary.degraded = true;
|
|
399
494
|
return summary;
|
|
400
495
|
}
|
|
401
|
-
|
|
496
|
+
/**
|
|
497
|
+
* Resolve an issue id to every file it could plausibly mean.
|
|
498
|
+
*
|
|
499
|
+
* Matching runs in tiers of decreasing specificity and returns only the FIRST
|
|
500
|
+
* NON-EMPTY tier. That ordering is load-bearing: an exact filename outranks an
|
|
501
|
+
* ISS-NNNN prefix, so passing a filename stays a reliable way to name one
|
|
502
|
+
* member of a duplicate-id pair. Ambiguity is only ever reported within a
|
|
503
|
+
* single tier, never across tiers.
|
|
504
|
+
*
|
|
505
|
+
* Returning an array rather than the first hit is the point. Duplicate ISS-NNNN
|
|
506
|
+
* ids exist in the wild (ISS-0131), and `files.find(...)` resolved them in
|
|
507
|
+
* fs.readdir order — so every writer had an undefined target, silently edited
|
|
508
|
+
* one record, silently left the other untouched, and returned success.
|
|
509
|
+
*/
|
|
510
|
+
async function findIssueCandidates(projectId, issueId) {
|
|
402
511
|
const resolved = resolveProjectPaths(projectId);
|
|
403
512
|
const issuesDir = resolved.subPath('sentinel', 'issues');
|
|
513
|
+
const at = (filename) => ({ filePath: path.join(issuesDir, filename), filename });
|
|
404
514
|
try {
|
|
405
515
|
const files = await fs.readdir(issuesDir);
|
|
406
|
-
//
|
|
516
|
+
// Tier 1: exact filename. A directory cannot hold two of these.
|
|
407
517
|
if (files.includes(issueId)) {
|
|
408
|
-
return
|
|
518
|
+
return [at(issueId)];
|
|
409
519
|
}
|
|
410
|
-
//
|
|
520
|
+
// Tier 2: filename + record extension. At most one per extension, and the
|
|
521
|
+
// extension order (.md, .yml, .yaml) is the established precedence.
|
|
411
522
|
if (!isRecordFile(issueId)) {
|
|
412
523
|
for (const ext of ['.md', '.yml', '.yaml']) {
|
|
413
524
|
const candidate = `${issueId}${ext}`;
|
|
414
525
|
if (files.includes(candidate)) {
|
|
415
|
-
return
|
|
526
|
+
return [at(candidate)];
|
|
416
527
|
}
|
|
417
528
|
}
|
|
418
529
|
}
|
|
@@ -420,14 +531,16 @@ async function findIssueFile(projectId, issueId) {
|
|
|
420
531
|
const issMatch = issueId.match(/^ISS-(\d+)$/i);
|
|
421
532
|
if (issMatch) {
|
|
422
533
|
const padded = `ISS-${issMatch[1].padStart(4, '0')}`.toLowerCase();
|
|
423
|
-
|
|
534
|
+
// Tier 3: filename prefix. THIS is where duplicates collide.
|
|
535
|
+
const prefixHits = files.filter((f) => {
|
|
424
536
|
const lower = f.toLowerCase();
|
|
425
537
|
return lower.startsWith(`${padded}-`) || stripRecordExt(lower) === padded;
|
|
426
538
|
});
|
|
427
|
-
if (
|
|
428
|
-
return
|
|
539
|
+
if (prefixHits.length > 0) {
|
|
540
|
+
return prefixHits.map(at);
|
|
429
541
|
}
|
|
430
|
-
//
|
|
542
|
+
// Tier 4: frontmatter id, for legacy retroactively-stamped issues.
|
|
543
|
+
const fmHits = [];
|
|
431
544
|
for (const f of files) {
|
|
432
545
|
if (!isRecordFile(f))
|
|
433
546
|
continue;
|
|
@@ -440,24 +553,23 @@ async function findIssueFile(projectId, issueId) {
|
|
|
440
553
|
if (!idLine)
|
|
441
554
|
continue;
|
|
442
555
|
const idVal = idLine.slice(idLine.indexOf(':') + 1).trim().toLowerCase();
|
|
443
|
-
if (idVal === padded)
|
|
444
|
-
|
|
445
|
-
}
|
|
556
|
+
if (idVal === padded)
|
|
557
|
+
fmHits.push(f);
|
|
446
558
|
}
|
|
447
559
|
catch {
|
|
448
560
|
// skip unreadable
|
|
449
561
|
}
|
|
450
562
|
}
|
|
563
|
+
if (fmHits.length > 0)
|
|
564
|
+
return fmHits.map(at);
|
|
451
565
|
}
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
return null;
|
|
566
|
+
// Tier 5: fuzzy substring. Genuinely ambiguous when it hits more than once
|
|
567
|
+
// (`ISS-011` spans ISS-0110, ISS-0112, ISS-0119), so it reports all of them.
|
|
568
|
+
const fuzzyHits = files.filter((f) => f.toLowerCase().includes(issueId.toLowerCase()));
|
|
569
|
+
return fuzzyHits.map(at);
|
|
458
570
|
}
|
|
459
571
|
catch {
|
|
460
|
-
return
|
|
572
|
+
return [];
|
|
461
573
|
}
|
|
462
574
|
}
|
|
463
575
|
async function getProjectIssues(projectId) {
|
|
@@ -485,7 +597,6 @@ async function getProjectIssues(projectId) {
|
|
|
485
597
|
// Issue Functions
|
|
486
598
|
// ============================================================================
|
|
487
599
|
export async function createIssue(input) {
|
|
488
|
-
// Resolve project paths first - fail fast if no project
|
|
489
600
|
let resolved;
|
|
490
601
|
try {
|
|
491
602
|
resolved = resolveProjectPaths(input.projectId);
|
|
@@ -493,67 +604,55 @@ export async function createIssue(input) {
|
|
|
493
604
|
catch (err) {
|
|
494
605
|
return makeProjectResolutionError('create issue');
|
|
495
606
|
}
|
|
496
|
-
|
|
497
|
-
//
|
|
607
|
+
// EPIC-0040. Validated at the write boundary, before anything touches disk.
|
|
608
|
+
// The codec reads a damaged identity leniently and warns, because a file that
|
|
609
|
+
// already exists must still decode; a NEW value gets no such latitude.
|
|
610
|
+
if (input.assignee !== undefined)
|
|
611
|
+
assertActor(input.assignee);
|
|
612
|
+
if (input.team !== undefined)
|
|
613
|
+
assertTeam(input.team);
|
|
614
|
+
// Epic validation stays here: it is a policy question about this project's
|
|
615
|
+
// epics, not a storage concern, so it does not belong behind the repository.
|
|
498
616
|
if (input.epic_id) {
|
|
499
617
|
const allEpics = await getAllEpics(input.projectId);
|
|
500
|
-
|
|
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 }));
|
|
618
|
+
if (!allEpics.some((e) => e.id === input.epic_id)) {
|
|
506
619
|
return {
|
|
507
620
|
error: 'EPIC_NOT_FOUND',
|
|
508
621
|
epic_id: input.epic_id,
|
|
509
622
|
message: `Unknown epic_id ${input.epic_id}.`,
|
|
510
|
-
suggested_epics:
|
|
623
|
+
suggested_epics: allEpics.slice(0, 5).map((e) => ({ id: e.id, title: e.title })),
|
|
511
624
|
};
|
|
512
625
|
}
|
|
513
626
|
}
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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);
|
|
627
|
+
const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
|
|
628
|
+
// The repository allocates and writes under one cross-process lock and stamps
|
|
629
|
+
// a uid at birth — which the previous writer did not, so every issue filed
|
|
630
|
+
// since the Phase 3 backfill was landing without a stable identity.
|
|
631
|
+
//
|
|
632
|
+
// Path traversal is not reachable here: the filename is built from an
|
|
633
|
+
// allocated ISS-NNNN plus a slug that strips everything outside [a-z0-9-],
|
|
634
|
+
// so a hostile title cannot escape the issues directory.
|
|
635
|
+
const created = await repo.create({
|
|
636
|
+
title: input.title,
|
|
637
|
+
details: input.details,
|
|
638
|
+
severity: input.severity,
|
|
639
|
+
epicId: input.epic_id,
|
|
640
|
+
project: resolved.id,
|
|
641
|
+
// Forwarded rather than dropped. CreateIssueSpec has accepted these all
|
|
642
|
+
// along; omitting them here meant a caller could pass priority:'high' and
|
|
643
|
+
// watch it vanish with no error, because create_issue's schema allows
|
|
644
|
+
// additional properties.
|
|
645
|
+
priority: input.priority,
|
|
646
|
+
tags: input.tags,
|
|
647
|
+
assignee: input.assignee,
|
|
648
|
+
team: input.team,
|
|
649
|
+
});
|
|
650
|
+
log(`Sentinel: Created issue at ${created.path} (project: ${resolved.id})`);
|
|
651
|
+
await emitCreateProvenance(`sentinel:issue:${created.filename}`, await fs.readFile(created.path, 'utf-8'), `Created issue: ${input.title}`, input.projectId);
|
|
553
652
|
return {
|
|
554
|
-
id:
|
|
555
|
-
timestamp,
|
|
556
|
-
path:
|
|
653
|
+
id: created.issue.id,
|
|
654
|
+
timestamp: created.issue.created_at ?? new Date().toISOString(),
|
|
655
|
+
path: created.path,
|
|
557
656
|
status: 'open',
|
|
558
657
|
epic_id: input.epic_id,
|
|
559
658
|
location: 'project',
|
|
@@ -627,7 +726,6 @@ function setYamlBlockField(lines, key, value) {
|
|
|
627
726
|
return out;
|
|
628
727
|
}
|
|
629
728
|
export async function closeIssue(input) {
|
|
630
|
-
// Resolve project paths first
|
|
631
729
|
let resolved;
|
|
632
730
|
try {
|
|
633
731
|
resolved = resolveProjectPaths(input.projectId);
|
|
@@ -635,85 +733,64 @@ export async function closeIssue(input) {
|
|
|
635
733
|
catch (err) {
|
|
636
734
|
return makeProjectResolutionError('close issue');
|
|
637
735
|
}
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
736
|
+
const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
|
|
737
|
+
try {
|
|
738
|
+
// One call replaces the format fork this function used to carry. The
|
|
739
|
+
// bare-YAML branch was where close_issue historically failed: its
|
|
740
|
+
// frontmatter-anchored regexes silently no-opped on a delimiter-less
|
|
741
|
+
// record, so status stayed `open` while the markdown resolution append
|
|
742
|
+
// still fired — corrupting the YAML and reporting success. The codec has
|
|
743
|
+
// one write path for both formats, so that divergence cannot recur.
|
|
744
|
+
const { stored: closed, preservedResolution } = await repo.close(input.issue_id, input.resolution ?? '', input.status ?? 'closed');
|
|
745
|
+
log(preservedResolution
|
|
746
|
+
? `Sentinel: ${closed.issue.id} was already closed; kept its resolution (${closed.path})`
|
|
747
|
+
: `Sentinel: Closed issue at ${closed.path}`);
|
|
641
748
|
return {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
749
|
+
id: closed.issue.id,
|
|
750
|
+
path: closed.path,
|
|
751
|
+
status: closed.issue.status,
|
|
752
|
+
closed_at: closed.issue.closed_at ?? new Date().toISOString(),
|
|
753
|
+
// The resolution the record NOW carries, which on a re-close is the one
|
|
754
|
+
// the first close wrote — not the one this call passed and did not apply.
|
|
755
|
+
resolution: closed.issue.resolution,
|
|
756
|
+
...(preservedResolution ? { already_closed: true } : {}),
|
|
647
757
|
};
|
|
648
758
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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}`);
|
|
759
|
+
catch (err) {
|
|
760
|
+
if (err instanceof RepoAmbiguousIssueIdError) {
|
|
761
|
+
return {
|
|
762
|
+
error: 'AMBIGUOUS_ISSUE_ID',
|
|
763
|
+
projectId: resolved.id,
|
|
764
|
+
issue_id: input.issue_id,
|
|
765
|
+
message: `"${input.issue_id}" matches ${err.candidates.length} records in project "${resolved.id}", ` +
|
|
766
|
+
`so the write target is undefined and nothing was changed. ` +
|
|
767
|
+
`Re-run with one of these filenames as issue_id: ${err.candidates.join(', ')}`,
|
|
768
|
+
candidates: err.candidates,
|
|
769
|
+
};
|
|
664
770
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// Add resolution section at the end
|
|
675
|
-
updatedContent = updatedContent.trimEnd() + `\n\n## Resolution\n\n${input.resolution}\n`;
|
|
676
|
-
}
|
|
771
|
+
if (err instanceof RepoIssueNotFoundError) {
|
|
772
|
+
const available = await getProjectIssues(input.projectId);
|
|
773
|
+
return {
|
|
774
|
+
error: 'ISSUE_NOT_FOUND',
|
|
775
|
+
projectId: resolved.id,
|
|
776
|
+
issue_id: input.issue_id,
|
|
777
|
+
message: `Could not find issue matching "${input.issue_id}" in project "${resolved.id}".`,
|
|
778
|
+
available_issues: available.slice(0, 5),
|
|
779
|
+
};
|
|
677
780
|
}
|
|
781
|
+
throw err;
|
|
678
782
|
}
|
|
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
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Delegates to the Phase 3 repository. The output contract is unchanged — the
|
|
786
|
+
* point of the seam is that callers cannot tell which layer answered.
|
|
787
|
+
*
|
|
788
|
+
* `degraded` changes meaning slightly and for the better: it used to mean "only
|
|
789
|
+
* parsed after salvaging corrupted YAML", and now means "readable, but the
|
|
790
|
+
* codec had to interpret something" — a superset that also catches a legacy
|
|
791
|
+
* status value or a title that fell back to the filename. Both are the same
|
|
792
|
+
* repair queue.
|
|
793
|
+
*/
|
|
717
794
|
export async function listRepoIssues(input) {
|
|
718
795
|
let resolved;
|
|
719
796
|
try {
|
|
@@ -722,47 +799,62 @@ export async function listRepoIssues(input) {
|
|
|
722
799
|
catch (err) {
|
|
723
800
|
return makeProjectResolutionError('list issues');
|
|
724
801
|
}
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
const
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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)
|
|
802
|
+
// Resolve the scope first: a malformed filter must fail before any read.
|
|
803
|
+
const scope = await resolveScope({ assignee: input.assignee, team: input.team });
|
|
804
|
+
const repo = new FsIssueRepository(resolved.subPath('sentinel', 'issues'));
|
|
805
|
+
const [allRecords, integrity] = await Promise.all([
|
|
806
|
+
repo.list(input.status ? { status: input.status } : {}),
|
|
807
|
+
repo.integrity(),
|
|
808
|
+
]);
|
|
809
|
+
const records = scope ? allRecords.filter((r) => inScope(r.issue, scope)) : allRecords;
|
|
810
|
+
const issues = records.map((r) => toIssueSummary(r, integrity));
|
|
754
811
|
issues.sort((a, b) => b.id.localeCompare(a.id));
|
|
755
812
|
const out = { issues };
|
|
756
|
-
if (
|
|
757
|
-
out.
|
|
758
|
-
|
|
813
|
+
if (scope)
|
|
814
|
+
out.scope = scope;
|
|
815
|
+
if (integrity.malformed.length > 0) {
|
|
816
|
+
out.malformed = integrity.malformed.length;
|
|
817
|
+
out.malformed_files = integrity.malformed;
|
|
759
818
|
}
|
|
760
|
-
if (
|
|
761
|
-
out.degraded =
|
|
762
|
-
out.degraded_files =
|
|
819
|
+
if (integrity.degraded.length > 0) {
|
|
820
|
+
out.degraded = integrity.degraded.length;
|
|
821
|
+
out.degraded_files = integrity.degraded.map((d) => d.filename);
|
|
822
|
+
}
|
|
823
|
+
const dupEntries = Object.entries(integrity.duplicateIds);
|
|
824
|
+
if (dupEntries.length > 0) {
|
|
825
|
+
out.duplicate_ids = dupEntries.length;
|
|
826
|
+
out.duplicate_id_files = Object.fromEntries(dupEntries);
|
|
763
827
|
}
|
|
764
828
|
return out;
|
|
765
829
|
}
|
|
830
|
+
/** Canonical record -> the wire shape this module has always returned. */
|
|
831
|
+
function toIssueSummary(r, integrity) {
|
|
832
|
+
// Records with no ISS-NNNN have always been identified on the wire by their
|
|
833
|
+
// filename INCLUDING the extension, and close_issue/read_issue accept that
|
|
834
|
+
// form. The canonical model drops the extension, which is tidier and which
|
|
835
|
+
// the repository resolves either way — but changing it here would be a wire
|
|
836
|
+
// contract change smuggled in on a refactor. ISS-0113 tracks the
|
|
837
|
+
// inconsistency deliberately; this migration stays behaviour-preserving.
|
|
838
|
+
const wireId = /^ISS-\d+$/i.test(r.issue.id) ? r.issue.id : r.filename;
|
|
839
|
+
const summary = {
|
|
840
|
+
id: wireId,
|
|
841
|
+
title: r.issue.title,
|
|
842
|
+
severity: r.issue.severity ?? 'low',
|
|
843
|
+
status: r.issue.status,
|
|
844
|
+
epic_id: r.issue.epicId,
|
|
845
|
+
priority: r.issue.priority,
|
|
846
|
+
tags: r.issue.tags,
|
|
847
|
+
created_at: r.issue.created_at,
|
|
848
|
+
updated_at: r.issue.updated_at,
|
|
849
|
+
};
|
|
850
|
+
if (r.issue.assignee)
|
|
851
|
+
summary.assignee = r.issue.assignee;
|
|
852
|
+
if (r.issue.team)
|
|
853
|
+
summary.team = r.issue.team;
|
|
854
|
+
if (integrity.degraded.some((d) => d.filename === r.filename))
|
|
855
|
+
summary.degraded = true;
|
|
856
|
+
return summary;
|
|
857
|
+
}
|
|
766
858
|
// ============================================================================
|
|
767
859
|
// Epic Functions
|
|
768
860
|
// ============================================================================
|
|
@@ -778,55 +870,71 @@ export async function logEpic(input) {
|
|
|
778
870
|
const now = new Date();
|
|
779
871
|
const timestamp = now.toISOString();
|
|
780
872
|
ensureDir(epicsDir);
|
|
781
|
-
|
|
782
|
-
|
|
873
|
+
// EPIC-0040. Validated before the allocator runs, so a bad identity cannot
|
|
874
|
+
// burn an epic number.
|
|
875
|
+
if (input.assignee)
|
|
876
|
+
assertActor(input.assignee);
|
|
877
|
+
if (input.team)
|
|
878
|
+
assertTeam(input.team);
|
|
783
879
|
const slug = slugify(input.title);
|
|
784
|
-
const filename = `${epicId}-${slug}.md`;
|
|
785
|
-
const filePath = path.join(epicsDir, filename);
|
|
786
|
-
validateWritePath(filePath, resolved);
|
|
787
880
|
const priority = input.priority || 'medium';
|
|
788
881
|
const tags = input.tags || [];
|
|
789
882
|
const owner = input.owner || '';
|
|
790
883
|
const squad = input.squad || '';
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
884
|
+
// EPIC-0040. `owner`/`squad` stay exactly as they are — ADR-0010 established
|
|
885
|
+
// that renaming a field breaks provenance references, and EPIC-0038 dropped
|
|
886
|
+
// its renames for that reason. These are the new, id-bearing fields; the old
|
|
887
|
+
// two remain as free text and are read as aliases.
|
|
888
|
+
const assignee = input.assignee || '';
|
|
889
|
+
const team = input.team || '';
|
|
890
|
+
// Content is built from the id the allocator hands out, so it cannot be
|
|
891
|
+
// assembled before allocation — the id appears in the frontmatter.
|
|
892
|
+
const buildContent = (epicId) => {
|
|
893
|
+
// Build frontmatter — use yamlScalar on free-text fields so values containing
|
|
894
|
+
// `#`, `:` etc. don't truncate on read-back (see ISS-0110 bug 3).
|
|
895
|
+
const frontmatter = [
|
|
896
|
+
'---',
|
|
897
|
+
`id: ${epicId}`,
|
|
898
|
+
`projectId: ${yamlScalar(resolved.id)}`,
|
|
899
|
+
`title: ${yamlScalar(input.title)}`,
|
|
900
|
+
`summary: ${yamlScalar(input.summary)}`,
|
|
901
|
+
`status: planned`,
|
|
902
|
+
`priority: ${priority}`,
|
|
903
|
+
`tags: [${tags.map(yamlScalar).join(', ')}]`,
|
|
904
|
+
`owner: ${yamlScalar(owner)}`,
|
|
905
|
+
`squad: ${yamlScalar(squad)}`,
|
|
906
|
+
`assignee: ${yamlScalar(assignee)}`,
|
|
907
|
+
`team: ${yamlScalar(team)}`,
|
|
908
|
+
`created_at: ${timestamp}`,
|
|
909
|
+
'---',
|
|
910
|
+
].join('\n');
|
|
911
|
+
// Build body sections
|
|
912
|
+
const sections = [];
|
|
913
|
+
sections.push(`# ${input.title}`, '', '## Summary', '', input.summary);
|
|
914
|
+
if (input.motivation && input.motivation.length > 0) {
|
|
915
|
+
sections.push('', '## Motivation', '');
|
|
916
|
+
for (const item of input.motivation) {
|
|
917
|
+
sections.push(`- ${item}`);
|
|
918
|
+
}
|
|
814
919
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
920
|
+
if (input.outcomes && input.outcomes.length > 0) {
|
|
921
|
+
sections.push('', '## Outcomes', '');
|
|
922
|
+
for (const item of input.outcomes) {
|
|
923
|
+
sections.push(`- ${item}`);
|
|
924
|
+
}
|
|
820
925
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
926
|
+
if (input.acceptance_criteria && input.acceptance_criteria.length > 0) {
|
|
927
|
+
sections.push('', '## Acceptance Criteria', '');
|
|
928
|
+
for (const item of input.acceptance_criteria) {
|
|
929
|
+
sections.push(`- [ ] ${item}`);
|
|
930
|
+
}
|
|
826
931
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
932
|
+
return `${frontmatter}\n\n${sections.join('\n')}\n`;
|
|
933
|
+
};
|
|
934
|
+
// Capture what actually landed rather than rebuilding it, so the provenance
|
|
935
|
+
// fingerprint below is of the bytes on disk and not of a second render.
|
|
936
|
+
let content = '';
|
|
937
|
+
const { id: epicId, filePath } = await allocateAndWriteEpic(epicsDir, slug, (id) => (content = buildContent(id)), { validate: (p) => validateWritePath(p, resolved) });
|
|
830
938
|
log(`Sentinel: Created epic at ${filePath} (project: ${resolved.id})`);
|
|
831
939
|
// Emit provenance event for this creation
|
|
832
940
|
await emitCreateProvenance(`sentinel:epic:${epicId}`, content, `Created epic: ${input.title}`, input.projectId);
|
|
@@ -846,40 +954,40 @@ export async function listEpics(input) {
|
|
|
846
954
|
return makeProjectResolutionError('list epics');
|
|
847
955
|
}
|
|
848
956
|
const epicsDir = resolved.subPath('sentinel', 'epics');
|
|
957
|
+
const scope = await resolveScope({ assignee: input.assignee, team: input.team });
|
|
849
958
|
const epics = [];
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (input.
|
|
959
|
+
// ISS-0153. The previous form was `catch { /* directory doesn't exist yet */ }`
|
|
960
|
+
// wrapped around BOTH the readdir and the parse loop, with `if (!epic)
|
|
961
|
+
// continue` inside it. So a permissions failure, a corrupt epic, and a project
|
|
962
|
+
// that has never logged one all returned `{epics: []}` — and the caller could
|
|
963
|
+
// not tell which. readStoreDir keeps them apart and counts what it skipped.
|
|
964
|
+
const read = await readStoreDir(epicsDir, parseEpicFile, isRecordFile);
|
|
965
|
+
for (const epic of read.entries) {
|
|
966
|
+
if (input.status && epic.status !== input.status)
|
|
967
|
+
continue;
|
|
968
|
+
if (input.priority && epic.priority !== input.priority)
|
|
969
|
+
continue;
|
|
970
|
+
if (input.tags && input.tags.length > 0) {
|
|
971
|
+
if (!input.tags.some((t) => epic.tags.includes(t)))
|
|
863
972
|
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
973
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
974
|
+
if (scope && !inScope(epic, scope))
|
|
975
|
+
continue;
|
|
976
|
+
const summary = {
|
|
977
|
+
id: epic.id,
|
|
978
|
+
title: epic.title,
|
|
979
|
+
status: epic.status,
|
|
980
|
+
priority: epic.priority,
|
|
981
|
+
};
|
|
982
|
+
if (epic.assignee)
|
|
983
|
+
summary.assignee = epic.assignee;
|
|
984
|
+
if (epic.team)
|
|
985
|
+
summary.team = epic.team;
|
|
986
|
+
epics.push(summary);
|
|
879
987
|
}
|
|
880
988
|
// Sort by ID (newest first based on number)
|
|
881
989
|
epics.sort((a, b) => b.id.localeCompare(a.id));
|
|
882
|
-
return { epics };
|
|
990
|
+
return { epics, ...(scope ? { scope } : {}), ...storeMeta(read) };
|
|
883
991
|
}
|
|
884
992
|
export async function getEpic(input) {
|
|
885
993
|
let resolved;
|
|
@@ -907,6 +1015,179 @@ export async function getEpic(input) {
|
|
|
907
1015
|
return { epic: null, error: `Epic not found: ${input.epic_id}` };
|
|
908
1016
|
}
|
|
909
1017
|
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Locate the file backing an epic id.
|
|
1020
|
+
*
|
|
1021
|
+
* Boundary-matched deliberately. A bare `startsWith` has no boundary, so
|
|
1022
|
+
* `EPIC-003` would match EPIC-0030, EPIC-0031 and EPIC-0038 at once and the
|
|
1023
|
+
* caller would silently write to whichever readdir returned first — the same
|
|
1024
|
+
* defect that made issue resolution unsafe (ISS-0131). Returning every match
|
|
1025
|
+
* lets the caller refuse rather than guess.
|
|
1026
|
+
*/
|
|
1027
|
+
async function findEpicFiles(epicsDir, epicId) {
|
|
1028
|
+
let files;
|
|
1029
|
+
try {
|
|
1030
|
+
files = await fs.readdir(epicsDir);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
return [];
|
|
1034
|
+
}
|
|
1035
|
+
const padded = epicId.match(/^EPIC-(\d+)$/i);
|
|
1036
|
+
const canonical = padded ? `epic-${padded[1].padStart(4, '0')}` : epicId.toLowerCase();
|
|
1037
|
+
return files.filter((f) => {
|
|
1038
|
+
if (!isRecordFile(f))
|
|
1039
|
+
return false;
|
|
1040
|
+
const lower = f.toLowerCase();
|
|
1041
|
+
// Exact filename, or the id followed by a non-digit separator.
|
|
1042
|
+
return lower === epicId.toLowerCase() || new RegExp(`^${canonical}(?![0-9])`).test(lower);
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Update an epic in place.
|
|
1047
|
+
*
|
|
1048
|
+
* Epics were write-once: log_epic created them and nothing could change status,
|
|
1049
|
+
* priority, summary, or ownership afterwards (ISS-0140). The practical effects
|
|
1050
|
+
* were that `status` stayed `planned` forever — making list_epics(status:...)
|
|
1051
|
+
* useless and feeding wrong epic state into oracle/roadmap reporting
|
|
1052
|
+
* (ISS-0110) — and that correcting an epic meant hand-editing the file, which
|
|
1053
|
+
* is the markdown-into-YAML write hazard that corrupted issue records in the
|
|
1054
|
+
* first place.
|
|
1055
|
+
*
|
|
1056
|
+
* Frontmatter is mutated as a parsed object and re-serialized, never patched by
|
|
1057
|
+
* regex, and the file is replaced atomically. The body's `## Summary` section
|
|
1058
|
+
* mirrors the frontmatter `summary` field, so both are updated together —
|
|
1059
|
+
* leaving them to drift is what makes hand-editing an epic error-prone.
|
|
1060
|
+
*/
|
|
1061
|
+
export async function updateEpic(input) {
|
|
1062
|
+
let resolved;
|
|
1063
|
+
try {
|
|
1064
|
+
resolved = resolveProjectPaths(input.projectId);
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return makeProjectResolutionError('update epic');
|
|
1068
|
+
}
|
|
1069
|
+
// EPIC-0040. Validated before the record is located or rewritten.
|
|
1070
|
+
if (input.assignee)
|
|
1071
|
+
assertActor(input.assignee);
|
|
1072
|
+
if (input.team)
|
|
1073
|
+
assertTeam(input.team);
|
|
1074
|
+
const epicsDir = resolved.subPath('sentinel', 'epics');
|
|
1075
|
+
const matches = await findEpicFiles(epicsDir, input.epic_id);
|
|
1076
|
+
if (matches.length === 0) {
|
|
1077
|
+
const all = await getAllEpics(input.projectId);
|
|
1078
|
+
return {
|
|
1079
|
+
error: 'EPIC_NOT_FOUND',
|
|
1080
|
+
epic_id: input.epic_id,
|
|
1081
|
+
message: `Unknown epic_id ${input.epic_id}.`,
|
|
1082
|
+
suggested_epics: all.slice(0, 5).map((e) => ({ id: e.id, title: e.title })),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
if (matches.length > 1) {
|
|
1086
|
+
return {
|
|
1087
|
+
error: 'AMBIGUOUS_EPIC_ID',
|
|
1088
|
+
epic_id: input.epic_id,
|
|
1089
|
+
message: `${matches.length} epic records match "${input.epic_id}". ` +
|
|
1090
|
+
`Pass an exact filename to disambiguate.`,
|
|
1091
|
+
candidates: matches,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const filePath = path.join(epicsDir, matches[0]);
|
|
1095
|
+
validateWritePath(filePath, resolved);
|
|
1096
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1097
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
1098
|
+
if (!fmMatch) {
|
|
1099
|
+
return {
|
|
1100
|
+
error: 'EPIC_NOT_FOUND',
|
|
1101
|
+
epic_id: input.epic_id,
|
|
1102
|
+
message: `Epic record ${matches[0]} has no parseable frontmatter; refusing to write into a malformed file.`,
|
|
1103
|
+
suggested_epics: [],
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
let parsed;
|
|
1107
|
+
try {
|
|
1108
|
+
parsed = safeParseYaml(fmMatch[1]);
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
return {
|
|
1112
|
+
error: 'EPIC_NOT_FOUND',
|
|
1113
|
+
epic_id: input.epic_id,
|
|
1114
|
+
message: `Epic record ${matches[0]} has unparseable frontmatter (${err instanceof Error ? err.message : String(err)}); refusing to write into it.`,
|
|
1115
|
+
suggested_epics: [],
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
let body = fmMatch[2];
|
|
1119
|
+
const changes = [];
|
|
1120
|
+
const applyScalar = (key, value, label = key) => {
|
|
1121
|
+
if (value === undefined)
|
|
1122
|
+
return;
|
|
1123
|
+
const before = parsed[key];
|
|
1124
|
+
if (before === value)
|
|
1125
|
+
return;
|
|
1126
|
+
parsed[key] = value;
|
|
1127
|
+
changes.push(`${label}: ${before ?? 'unset'} → ${value}`);
|
|
1128
|
+
};
|
|
1129
|
+
applyScalar('status', input.status);
|
|
1130
|
+
applyScalar('priority', input.priority);
|
|
1131
|
+
applyScalar('title', input.title);
|
|
1132
|
+
applyScalar('owner', input.owner);
|
|
1133
|
+
applyScalar('squad', input.squad);
|
|
1134
|
+
applyScalar('assignee', input.assignee);
|
|
1135
|
+
applyScalar('team', input.team);
|
|
1136
|
+
if (input.tags !== undefined) {
|
|
1137
|
+
parsed.tags = input.tags;
|
|
1138
|
+
changes.push(`tags: ${input.tags.length} tag(s)`);
|
|
1139
|
+
}
|
|
1140
|
+
if (input.summary !== undefined) {
|
|
1141
|
+
parsed.summary = input.summary;
|
|
1142
|
+
changes.push('summary rewritten');
|
|
1143
|
+
// Keep the body's Summary section in step with frontmatter. They are two
|
|
1144
|
+
// copies of one value; letting them diverge is what makes an epic
|
|
1145
|
+
// untrustworthy to read.
|
|
1146
|
+
const summarySection = /^## Summary\s*\n[\s\S]*?(?=\n## |\s*$)/m;
|
|
1147
|
+
const replacement = `## Summary\n\n${input.summary}\n`;
|
|
1148
|
+
if (summarySection.test(body)) {
|
|
1149
|
+
body = body.replace(summarySection, replacement);
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
// Ahead of the notes, not after them. A note section ends at the next
|
|
1153
|
+
// note heading (see extractNotes), so a Summary appended past the last
|
|
1154
|
+
// note would be read back as part of that note's text.
|
|
1155
|
+
const firstNote = body.search(/^##\s+Note\s*\([^)]*\)\s*$/m);
|
|
1156
|
+
body = firstNote === -1
|
|
1157
|
+
? `${body.trimEnd()}\n\n${replacement}`
|
|
1158
|
+
: `${body.slice(0, firstNote).trimEnd()}\n\n${replacement}\n${body.slice(firstNote)}`;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (input.note) {
|
|
1162
|
+
const stamp = new Date().toISOString();
|
|
1163
|
+
body = `${body.trimEnd()}\n\n## Note (${stamp})\n\n${input.note}\n`;
|
|
1164
|
+
changes.push('note appended');
|
|
1165
|
+
}
|
|
1166
|
+
if (changes.length === 0) {
|
|
1167
|
+
return {
|
|
1168
|
+
id: String(parsed.id ?? input.epic_id),
|
|
1169
|
+
path: filePath,
|
|
1170
|
+
status: parsed.status ?? 'planned',
|
|
1171
|
+
priority: parsed.priority ?? 'medium',
|
|
1172
|
+
updated_at: String(parsed.updated_at ?? ''),
|
|
1173
|
+
changes: [],
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
const updatedAt = new Date().toISOString();
|
|
1177
|
+
parsed.updated_at = updatedAt;
|
|
1178
|
+
const frontmatter = stringifyYaml(parsed, { lineWidth: 0 }).trimEnd();
|
|
1179
|
+
const rebuilt = `---\n${frontmatter}\n---\n\n${body.replace(/^\n+/, '').trimEnd()}\n`;
|
|
1180
|
+
await writeFileAtomic(filePath, rebuilt);
|
|
1181
|
+
log(`Sentinel: Updated epic ${parsed.id} at ${filePath} — ${changes.join(', ')}`);
|
|
1182
|
+
return {
|
|
1183
|
+
id: String(parsed.id ?? input.epic_id),
|
|
1184
|
+
path: filePath,
|
|
1185
|
+
status: parsed.status ?? 'planned',
|
|
1186
|
+
priority: parsed.priority ?? 'medium',
|
|
1187
|
+
updated_at: updatedAt,
|
|
1188
|
+
changes,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
910
1191
|
export async function getEpicIssues(input) {
|
|
911
1192
|
let resolved;
|
|
912
1193
|
try {
|