@ornexus/neocortex 4.60.71 → 4.60.73
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/dist/release-source.json +1 -1
- package/dist/sbom.cdx.json +1211 -1217
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/package.json +2 -2
- package/packages/client/dist/adapters/adapter-registry.js +108 -1
- package/packages/client/dist/adapters/antigravity-adapter.js +77 -2
- package/packages/client/dist/adapters/claude-code-adapter.js +79 -3
- package/packages/client/dist/adapters/codex-adapter.js +80 -2
- package/packages/client/dist/adapters/cursor-adapter.js +115 -4
- package/packages/client/dist/adapters/gemini-adapter.js +71 -2
- package/packages/client/dist/adapters/grok-adapter.js +42 -2
- package/packages/client/dist/adapters/index.js +22 -1
- package/packages/client/dist/adapters/platform-detector.js +212 -1
- package/packages/client/dist/adapters/target-adapter.js +12 -0
- package/packages/client/dist/adapters/vscode-adapter.js +72 -2
- package/packages/client/dist/agent/refresh-stubs.js +417 -2
- package/packages/client/dist/agent/update-agent-yaml.js +102 -1
- package/packages/client/dist/agent/update-description.js +251 -1
- package/packages/client/dist/cache/crypto-utils.js +78 -1
- package/packages/client/dist/cache/encrypted-cache.js +108 -1
- package/packages/client/dist/cache/in-memory-asset-cache.js +73 -1
- package/packages/client/dist/cache/index.js +13 -1
- package/packages/client/dist/cache/protected-pi-boundary.js +44 -1
- package/packages/client/dist/checkpoint/checkpoint-client-reader.js +127 -2
- package/packages/client/dist/checkpoint/index.js +11 -1
- package/packages/client/dist/checkpoint/shared-checkpoint-types.js +32 -1
- package/packages/client/dist/cli.js +358 -5
- package/packages/client/dist/commands/activate.js +394 -8
- package/packages/client/dist/commands/architecture-answers.js +274 -7
- package/packages/client/dist/commands/architecture-memory-terminal.js +253 -4
- package/packages/client/dist/commands/architecture-non-ui-continuation.js +100 -1
- package/packages/client/dist/commands/architecture-root-binding.js +51 -1
- package/packages/client/dist/commands/cache-status.js +112 -2
- package/packages/client/dist/commands/config.js +88 -1
- package/packages/client/dist/commands/doctor.js +635 -2
- package/packages/client/dist/commands/invoke.js +7200 -143
- package/packages/client/dist/commands/legacy-epic-terminal.js +71 -1
- package/packages/client/dist/commands/legacy-story-branch.js +96 -1
- package/packages/client/dist/commands/legacy-story-pr.js +276 -1
- package/packages/client/dist/commands/mutation-qa-checkpoint.js +285 -5
- package/packages/client/dist/commands/mutation-qa-gate.js +54 -1
- package/packages/client/dist/commands/mutation-qa-git.js +41 -1
- package/packages/client/dist/commands/mutation-qa-policy.js +102 -1
- package/packages/client/dist/commands/mutation-qa-run.js +219 -3
- package/packages/client/dist/commands/mutation-qa-verdict.js +149 -1
- package/packages/client/dist/commands/mutation-qa.js +220 -1
- package/packages/client/dist/commands/native-agent.js +136 -2
- package/packages/client/dist/commands/planning-diagnosis.js +43 -1
- package/packages/client/dist/commands/planning-generation.js +53 -1
- package/packages/client/dist/commands/planning-memory.js +40 -1
- package/packages/client/dist/commands/planning-research.js +42 -1
- package/packages/client/dist/commands/private-session.js +75 -1
- package/packages/client/dist/commands/property-qa.js +139 -1
- package/packages/client/dist/commands/qa-control.js +16 -1
- package/packages/client/dist/commands/qa-scan-browser-launch.js +55 -1
- package/packages/client/dist/commands/qa-scan-browser-route.js +28 -1
- package/packages/client/dist/commands/qa-scan-capture.js +663 -5
- package/packages/client/dist/commands/qa-scan-discovery.js +376 -3
- package/packages/client/dist/commands/qa-scan-review.js +826 -5
- package/packages/client/dist/commands/qa-synthetic-backend-capture.js +266 -1
- package/packages/client/dist/commands/qa-synthetic-backend-review.js +297 -1
- package/packages/client/dist/commands/qa-synthetic-backend.js +340 -2
- package/packages/client/dist/commands/qa-synthetic-broker-port.js +85 -1
- package/packages/client/dist/commands/qa-synthetic-browser.js +39 -1
- package/packages/client/dist/commands/refactor-characterization.js +671 -21
- package/packages/client/dist/commands/refactor-commit.js +399 -13
- package/packages/client/dist/commands/refactor-control.js +142 -3
- package/packages/client/dist/commands/refactor-cpp-assertions.js +93 -3
- package/packages/client/dist/commands/refactor-review.js +283 -5
- package/packages/client/dist/commands/refactor-root.js +588 -7
- package/packages/client/dist/commands/refresh-memory.js +156 -3
- package/packages/client/dist/commands/shared-architecture-non-ui.js +24 -1
- package/packages/client/dist/commands/story-checkpoint.js +171 -1
- package/packages/client/dist/config/profile-directory.js +13 -1
- package/packages/client/dist/config/resolver-selection.js +278 -1
- package/packages/client/dist/config/secure-config.js +281 -12
- package/packages/client/dist/config.js +223 -2
- package/packages/client/dist/constants.js +25 -1
- package/packages/client/dist/context/context-collector.js +222 -2
- package/packages/client/dist/context/context-sanitizer.js +145 -1
- package/packages/client/dist/continuity/autonomous-governance.js +285 -1
- package/packages/client/dist/continuity/continuity-client-state-store.js +1587 -1
- package/packages/client/dist/continuity/invoke-hooks.js +129 -1
- package/packages/client/dist/continuity/migrations/001-initial-schema.js +12 -2
- package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +12 -2
- package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +12 -2
- package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +12 -2
- package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +12 -2
- package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +10 -2
- package/packages/client/dist/continuity/native-agent-store.js +69 -1
- package/packages/client/dist/continuity/native-agent-types.js +90 -1
- package/packages/client/dist/continuity/outbox.js +117 -1
- package/packages/client/dist/continuity/runner-bootstrap-policy.js +172 -1
- package/packages/client/dist/continuity/sqlite-store.d.ts +22 -1
- package/packages/client/dist/continuity/sqlite-store.js +2612 -81
- package/packages/client/dist/effects/invoke-effect-gateways.js +62 -1
- package/packages/client/dist/errors/error-messages.js +228 -2
- package/packages/client/dist/errors/frontmatter-diagnostics.js +125 -1
- package/packages/client/dist/evidence/collector.js +347 -1
- package/packages/client/dist/evidence/index.js +1 -1
- package/packages/client/dist/evidence/types.js +2 -0
- package/packages/client/dist/fs/atomic-state-replace.js +152 -1
- package/packages/client/dist/generated/command-registry.generated.js +4076 -1
- package/packages/client/dist/generated/public-projections.generated.js +149 -1
- package/packages/client/dist/git/direct-yolo-c01-retry.js +138 -4
- package/packages/client/dist/git/direct-yolo-checkpoint.js +1234 -4
- package/packages/client/dist/git/epic-operational-state.d.ts +198 -0
- package/packages/client/dist/git/epic-operational-state.js +672 -0
- package/packages/client/dist/git/git-lifecycle-step-ids.js +14 -1
- package/packages/client/dist/git/git-lifecycle-store.d.ts +9 -1
- package/packages/client/dist/git/git-lifecycle-store.js +1981 -9
- package/packages/client/dist/git/legacy-story-branch-checkpoint.js +259 -9
- package/packages/client/dist/git/legacy-story-branch-setup.js +348 -4
- package/packages/client/dist/git/legacy-story-pr.js +3538 -70
- package/packages/client/dist/git/secure-filesystem-boundary.js +182 -5
- package/packages/client/dist/graph-retrieval/pre-command-hook.js +284 -1
- package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +237 -1
- package/packages/client/dist/i18n/first-run.js +60 -2
- package/packages/client/dist/index.js +47 -1
- package/packages/client/dist/invocation/invoke-input.js +24 -1
- package/packages/client/dist/license/index.js +5 -1
- package/packages/client/dist/license/license-client.js +304 -1
- package/packages/client/dist/machine/fingerprint.js +163 -2
- package/packages/client/dist/machine/index.js +5 -1
- package/packages/client/dist/memory/project-memory-path-scope.js +62 -1
- package/packages/client/dist/memory/project-memory-writer.js +1007 -40
- package/packages/client/dist/memory/shared-project-memory-types.js +253 -2
- package/packages/client/dist/migrations/epic-lifecycle-backfill.d.ts +97 -0
- package/packages/client/dist/migrations/epic-lifecycle-backfill.js +1060 -0
- package/packages/client/dist/migrations/project-identity-capability.js +50 -1
- package/packages/client/dist/migrations/project-identity.js +493 -7
- package/packages/client/dist/migrations/state-json.d.ts +22 -0
- package/packages/client/dist/migrations/state-json.js +393 -4
- package/packages/client/dist/policy/architecture-policy.js +184 -2
- package/packages/client/dist/policy/index.js +7 -1
- package/packages/client/dist/policy/shared-policy-types.js +38 -0
- package/packages/client/dist/policy/subagent-approval-policy.js +55 -1
- package/packages/client/dist/private-execution/clean-baseline.js +279 -2
- package/packages/client/dist/private-execution/codex-executor.js +574 -4
- package/packages/client/dist/private-execution/codex-host-runtime.js +137 -1
- package/packages/client/dist/private-execution/codex-lifecycle-guard.js +97 -1
- package/packages/client/dist/private-execution/codex-scoped-permissions.js +128 -1
- package/packages/client/dist/private-execution/delivery.js +410 -13
- package/packages/client/dist/private-execution/diagnosis-root-review.js +294 -2
- package/packages/client/dist/private-execution/frozen-handoff.js +19 -6
- package/packages/client/dist/private-execution/implementation-validation.js +106 -2
- package/packages/client/dist/private-execution/isolated-admission.js +1432 -23
- package/packages/client/dist/private-execution/isolated-session.js +725 -5
- package/packages/client/dist/private-execution/isolated-workspace.js +122 -1
- package/packages/client/dist/private-execution/pact-consumer-runtime.js +409 -3
- package/packages/client/dist/private-execution/pact-test-source.js +258 -1
- package/packages/client/dist/private-execution/pact-toolchain.js +290 -3
- package/packages/client/dist/private-execution/pact-write-set.js +172 -1
- package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +406 -5
- package/packages/client/dist/private-execution/planning-generation-checkpoint.js +462 -5
- package/packages/client/dist/private-execution/planning-memory-checkpoint.js +139 -3
- package/packages/client/dist/private-execution/planning-research-checkpoint.js +325 -4
- package/packages/client/dist/private-execution/private-report-store.js +42 -1
- package/packages/client/dist/private-execution/property-test-candidate.js +103 -1
- package/packages/client/dist/private-execution/property-test-choices.js +147 -3
- package/packages/client/dist/private-execution/property-test-plan.js +271 -7
- package/packages/client/dist/private-execution/property-test-review.js +258 -4
- package/packages/client/dist/private-execution/property-test-runtime.js +372 -9
- package/packages/client/dist/private-execution/property-test-source.js +139 -1
- package/packages/client/dist/private-execution/root-checkpoint.js +80 -1
- package/packages/client/dist/private-execution/root-memory-git-evidence.js +135 -3
- package/packages/client/dist/private-execution/root-memory-recovery.js +93 -1
- package/packages/client/dist/private-execution/root-memory-request.js +29 -1
- package/packages/client/dist/private-execution/root-memory-scope.js +178 -1
- package/packages/client/dist/private-execution/root-memory-writer.js +102 -1
- package/packages/client/dist/private-execution/session-lease.js +63 -1
- package/packages/client/dist/private-execution/shared-private-admission-types.js +50 -1
- package/packages/client/dist/private-execution/shared-project-validation.js +66 -1
- package/packages/client/dist/private-execution/shared-root-memory-types.js +61 -1
- package/packages/client/dist/private-execution/ux-source-validation.js +372 -1
- package/packages/client/dist/private-execution/ux-write-set.js +191 -2
- package/packages/client/dist/registry/registry-reconciliation.js +234 -1
- package/packages/client/dist/resilience/circuit-breaker.js +170 -1
- package/packages/client/dist/resilience/degradation-manager.js +164 -1
- package/packages/client/dist/resilience/freshness-indicator.js +100 -1
- package/packages/client/dist/resilience/index.js +8 -1
- package/packages/client/dist/resilience/recovery-detector.js +74 -1
- package/packages/client/dist/resolvers/asset-resolver.js +13 -0
- package/packages/client/dist/resolvers/local-resolver.js +255 -8
- package/packages/client/dist/resolvers/remote-resolver.js +233 -1
- package/packages/client/dist/runner/binary.js +404 -1
- package/packages/client/dist/runner/cli.js +1571 -17
- package/packages/client/dist/runner/headless/adapters.js +49 -1
- package/packages/client/dist/runner/headless/claude-code-adapter.js +79 -1
- package/packages/client/dist/runner/headless/continuous-story-workspace.js +1628 -11
- package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +69 -1
- package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +110 -2
- package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +1236 -8
- package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +318 -2
- package/packages/client/dist/runner/headless/fake-adapter.js +62 -1
- package/packages/client/dist/runner/headless/managed-profile.js +132 -1
- package/packages/client/dist/runner/headless/opencode-adapter.js +145 -2
- package/packages/client/dist/runner/headless/prepared-story-bundle.js +43 -1
- package/packages/client/dist/runner/headless/resolver.js +231 -1
- package/packages/client/dist/runner/headless/run-yolo.js +885 -1
- package/packages/client/dist/runner/headless/run-yoloop.js +2574 -2
- package/packages/client/dist/runner/headless/types.js +574 -2
- package/packages/client/dist/runner/local-console.js +396 -13
- package/packages/client/dist/runner/portal-sync-worker.js +1487 -1
- package/packages/client/dist/runner/project-identity.js +106 -1
- package/packages/client/dist/runner/scheduler.js +995 -7
- package/packages/client/dist/runner-cli.js +18 -2
- package/packages/client/dist/state/invocation-shape.js +13 -1
- package/packages/client/dist/state/project-state-snapshot.d.ts +8 -0
- package/packages/client/dist/state/project-state-snapshot.js +1731 -1
- package/packages/client/dist/state/state-json-repair.js +785 -3
- package/packages/client/dist/state/structural-dag-projection.js +118 -1
- package/packages/client/dist/telemetry/index.js +5 -1
- package/packages/client/dist/telemetry/offline-queue.js +131 -1
- package/packages/client/dist/tier/index.js +5 -1
- package/packages/client/dist/tier/tier-aware-client.js +405 -1
- package/packages/client/dist/types/index.js +38 -1
- package/packages/client/dist/version.js +27 -1
- package/packages/client/dist/yolo/change-evidence-collector.js +126 -1
- package/packages/client/dist/yolo/invoke-envelope-capability.js +62 -1
- package/packages/client/dist/yolo/spec-envelope-collector.js +184 -5
- package/packages/client/dist/yoloop/discovery-hook.js +300 -2
- package/packages/client/dist/yoloop/index.js +8 -1
- package/packages/client/dist/yoloop/invoke-hooks.js +669 -12
- package/packages/client/dist/yoloop/shared-discover-epics.js +1007 -1
- package/packages/client/dist/yoloop/shared-yoloop-types.d.ts +65 -1
- package/packages/client/dist/yoloop/shared-yoloop-types.js +33 -1
- package/packages/client/dist/yoloop/target-normalization.js +180 -1
- package/packages/client/dist/yoloop/yoloop-client-state-store.d.ts +8 -0
- package/packages/client/dist/yoloop/yoloop-client-state-store.js +1920 -1
- package/packages/shared/dist/yolo/spec-envelope.js +3 -6
- package/targets-stubs/antigravity/gemini.md +1 -1
- package/targets-stubs/antigravity/skill/SKILL.md +1 -1
- package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex-root.md +2 -2
- package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex.md +2 -2
- package/targets-stubs/codex/AGENTS.md +1 -1
- package/targets-stubs/cursor/agent.md +2 -2
- package/targets-stubs/gemini-cli/agent.md +2 -2
- package/targets-stubs/opencode/neocortex-root.md +1 -1
- package/targets-stubs/vscode/neocortex.agent.md +2 -2
|
@@ -1 +1,1007 @@
|
|
|
1
|
-
const ge=Object.freeze({epicId:"P{number}",epicFilename:"docs/epics/epic-P{number}.md",storyId:"P{number}.NN",storyFilename:"docs/stories/P{number}.NN.story.md",acceptedOnly:Object.freeze({epicFilenames:Object.freeze(["docs/epics/epic-{number}.md","docs/epics/EPIC-{number}-{slug}.md","docs/epics/EPIC-{number}_{slug}.md","docs/epics/epic-{slug}.md"]),storyFilenames:Object.freeze(["docs/stories/{number}.NN.story.md","docs/stories/story-P{number}.NN.md","docs/stories/story-{number}.NN.md","docs/stories/EPIC-{number}.STORY-{number}.md"])})}),O=Object.freeze([/^epic-(P?\d+(?:\.\d+)?)\.md$/i,/^EPIC-(\d+)(?:[-_].*)?\.md$/,/^epic-([a-z0-9][a-z0-9_-]*)\.md$/i]),D=Object.freeze([/^(P?\d+\.\d+(?:\.\d+)?)\.story\.md$/i,/^story-(P?\d+\.\d+)\.md$/i,/^EPIC-(\d+)\.STORY-(\d+)\.md$/]),k=Object.freeze([".archive","_archive","_archives","_drafts","archive","archives","archived","drafts"]),F=/^---\s*\n([\s\S]*?)\n---/;function z(t,e){const i=new RegExp(`^${e}:\\s*(.+?)\\s*$`,"mi"),n=t.match(i);if(!n)return;let c=n[1].trim();return(c.startsWith('"')&&c.endsWith('"')||c.startsWith("'")&&c.endsWith("'"))&&(c=c.slice(1,-1)),c||void 0}function G(t){if(typeof t!="string"||t.length===0)return{};const e=t.match(F);if(!e)return{};const i=e[1];return{id:z(i,"id"),title:z(i,"title"),status:z(i,"status"),epic_id:z(i,"epic_id"),depends_on:te(i),files_to_modify:P(i,"files_to_modify"),total_stories:x(z(i,"total_stories"))}}const Y=32,B=4096,H=/^\s*\*\*([^*\r\n]+?):\*\*\s*(.*?)\s*$/,U=new Set(["backlog","pending","ready","planning","in-progress","done","completed","merged","cancelled","canceled","abandoned","draft","template","archived","branch-ready","diagnose","research","write-spec","create-tasks","implemented","refactor-suggest-done","property-tests-emitted","tests-validated","qa-gate-passed","memory-updated","committed","synced","pr-created","pr-reviewed","partial"]);function L(t){let e=t.trim();return e.startsWith("`")&&e.endsWith("`")&&e.length>=2&&(e=e.slice(1,-1).trim()),(e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'"))&&(e=e.slice(1,-1).trim()),e}function x(t){if(t===void 0||!/^\d{1,5}$/.test(t.trim()))return;const e=Number(t);return Number.isSafeInteger(e)?e:void 0}function Z(t){const e=L(t).toLowerCase().replace(/[_\s]+/g,"-"),n={planned:"planning",complete:"completed",closed:"done",finished:"done"}[e]??e;return U.has(n)?n:void 0}function X(t){const e=t.trim().toLowerCase().replace(/[_-]+/g," ").replace(/\s+/g," ");if(e==="id"||e==="epic id")return"id";if(e==="title"||e==="titulo")return"title";if(e==="status")return"status";if(e==="total stories"||e==="total de stories")return"total_stories"}function q(t){const e=t.find(c=>/^#\s+\S/.test(c));if(!e)return;const i=e.replace(/^#\s+/,"").trim();return(i.replace(/^(?:Epic|Story)\s+\S+\s*(?:(?:--?|:)|[\u2013\u2014])\s*/i,"")||i).slice(0,200)}function j(t,e,i,n){const c=[...new Set(t.map(L).filter(Boolean))];if(c.length>1){n.push(`legacy metadata ambiguous for '${e}' in '${i}': conflicting values ignored`);return}return c[0]}function K(t,e){const i=[],n=[],c=t.slice(0,B).split(/\r?\n/,Y);for(const m of c){if(n.length>0&&(/^\s*---\s*$/.test(m)||/^##\s+/.test(m)))break;n.push(m)}const o=new Map;for(const m of n){const r=m.match(H);if(!r)continue;const a=X(r[1]);if(!a)continue;const d=o.get(a)??[];d.push(r[2]),o.set(a,d)}const f=j(o.get("id")??[],"id",e,i),l=j(o.get("title")??[],"title",e,i),_=j(o.get("status")??[],"status",e,i),g=j(o.get("total_stories")??[],"total_stories",e,i),p=_===void 0?void 0:Z(_);_!==void 0&&p===void 0&&i.push(`legacy metadata status unrecognized in '${e}': value ignored`);const b=x(g);g!==void 0&&b===void 0&&i.push(`legacy metadata total_stories invalid in '${e}': value ignored`);const $=f&&/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(f)?f:void 0;f!==void 0&&$===void 0&&i.push(`legacy metadata id invalid in '${e}': value ignored`);const s=q(n),u=l??s;return{metadata:{...$?{id:$}:{},...u?{title:u.slice(0,200)}:{},...p?{status:p}:{},...b!==void 0?{total_stories:b}:{}},source:"legacy-metadata",warnings:Object.freeze(i)}}function V(t){return/^[A-Za-z0-9._-]{1,160}$/.test(t)?t:"markdown-target.md"}function J(t){return new TextEncoder().encode(t).length}function Q(t){let e=!1;for(const i of t.split(/\r?\n/)){if(!i.trim()||i.trimStart().startsWith("#"))continue;const n=i.match(/^\s+-\s+(.+?)\s*$/);if(n){if(!e)return!0;const f=n[1].trim();if(f.startsWith('"')&&!f.endsWith('"')||f.startsWith("'")&&!f.endsWith("'"))return!0;continue}if(/^\s+/.test(i))return!0;const c=i.match(/^[A-Za-z0-9_-]+:\s*(.*?)\s*$/);if(!c)return!0;const o=c[1].trim();if(e=o.length===0,o.startsWith('"')&&!o.endsWith('"')||o.startsWith("'")&&!o.endsWith("'"))return!0}return!1}function ee(t,e,i){const n=V(e),c=t.match(F);if(!c)return Object.freeze([`metadata-frontmatter reason=frontmatter-malformed file='${n}': repair the bounded leading YAML envelope; source content omitted`]);const o=c[1];return J(o)>8*1024||o.split(/\r?\n/).length>128?Object.freeze([`metadata-frontmatter reason=frontmatter-oversized file='${n}': reduce the leading YAML envelope; source content omitted`]):Q(o)||o.trim().length>0&&Object.keys(i).length===0?Object.freeze([`metadata-frontmatter reason=frontmatter-unsupported-value file='${n}': use bounded scalar fields or string lists; source content omitted`]):Object.freeze([])}function N(t,e){if(/^---(?:\s*\r?\n|\s*$)/.test(t)){const i=G(t);return{metadata:i,source:"frontmatter",warnings:ee(t,e,i)}}return typeof t!="string"||t.length===0?{metadata:{},source:"none",warnings:Object.freeze([])}:K(t,e)}function R(t){let e=t.trim();if((e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'"))&&(e=e.slice(1,-1).trim()),!!e&&!/^(null|undefined|true|false)$/i.test(e))return e}function te(t){return P(t,"depends_on")}function P(t,e){const i=e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),c=t.match(new RegExp(`^${i}:[ \\t]*(.+?)[ \\t]*$`,"mi"))?.[1]?.trim();if(c!==void 0){const g=(c.startsWith("[")&&c.endsWith("]")?c.slice(1,-1).split(","):[c]).map(R).filter(p=>typeof p=="string"&&p.length>0);return g.length>0?Object.freeze(g):void 0}const o=t.split(/\r?\n/),f=[];let l=!1;for(const _ of o){if(!l){new RegExp(`^${i}:\\s*$`,"i").test(_.trim())&&(l=!0);continue}if(/^[A-Za-z0-9_-]+:\s*/.test(_))break;const g=_.match(/^\s*-\s*(.+?)\s*$/);if(!g)continue;const p=R(g[1]);p&&f.push(p)}return f.length>0?Object.freeze(f):void 0}function T(t){return t.startsWith("_")||t.startsWith(".")}function ie(t){return k.includes(t)}async function he(){const t=await import("node:fs"),e=await import("node:path");return{isDirectory(i){try{return t.statSync(i).isDirectory()}catch{return!1}},readdir(i){try{return t.readdirSync(i)}catch{return[]}},readFile(i){try{return t.readFileSync(i,"utf8")}catch{return""}},join(...i){try{return e.join(...i)}catch{return i.join("/")}}}}function ne(t,e){let i=!1,n=null,c=null;for(let o=0;o<O.length;o++){const f=O[o],l=t.match(f);if(l){i=!0,n=l[1],c=o===2?"filename-slug":"filename-canonical";break}}return i?e.id&&e.id.length>0?{id:e.id,source:"frontmatter"}:{id:n,source:c}:null}function se(t){for(const e of O){const i=t.match(e);if(i)return i[1]}return null}function I(t){const e=t.trim();return/^\d+(?:\.\d+)*$/i.test(e)?`P${e}`.toUpperCase():e.toUpperCase()}function re(t){const e=I(t);return/^P\d+(?:\.\d+)?$/i.test(e)?`epic-${e}.md`:`epic-${t}.md`}function oe(t){const e=I(t);return/^P\d+\.\d+(?:\.\d+)?$/i.test(e)?`${e}.story.md`:`${t}.story.md`}function C(t,e){return!t||!e?!1:I(t)!==I(e)}function ce(t,e,i){return`frontmatter/filename mismatch: epic '${t}' has frontmatter id '${e}' but filename implies '${i}'. Repair by using generated canonical '${re(e)}' or changing frontmatter id to '${i}'.`}function ae(t,e,i){return`frontmatter/filename mismatch: story '${t}' has frontmatter id '${e}' but filename implies '${i}'. Repair by using generated canonical '${oe(e)}' or changing frontmatter id to '${i}'.`}function de(t){let e=t.match(D[0]);if(e){const i=e[1],n=i.indexOf(".");return{id:i,epic_id_hint:n>0?i.slice(0,n):null}}if(e=t.match(D[1]),e){const i=e[1],n=i.indexOf(".");return{id:i,epic_id_hint:n>0?i.slice(0,n):null}}if(e=t.match(D[2]),e){const i=e[1],n=e[2];return{id:`${i}.${n}`,epic_id_hint:i}}return null}function ue(t,e){return e.join(t,"..","stories")}function _e(t,e){const i=[],n=[],c=[],o=new Date().toISOString();if(typeof t!="string"||t.length===0)return Object.freeze({epics:Object.freeze([]),warnings:Object.freeze([]),skipped:Object.freeze([])});if(!e.isDirectory(t))return Object.freeze({epics:Object.freeze([]),warnings:Object.freeze([]),skipped:Object.freeze([])});const f=[...e.readdir(t)].sort(),l=[];for(const s of f){const u=e.join(t,s);if(T(s)){n.push(`${s}: ignored (draft or hidden prefix)`);continue}if(e.isDirectory(u)){if(ie(s)){n.push(`${s}: ignored subdirectory`);continue}n.push(`${s}: subdirectory not recursed`);continue}if(!s.endsWith(".md"))continue;if(!O.some(h=>h.test(s))){n.push(`${s}: did not match any EPIC_FILE_PATTERNS`);continue}const m=e.readFile(u),r=N(m,s);i.push(...r.warnings);const a=r.metadata,d=se(s);a.id&&C(a.id,d)&&i.push(r.source==="frontmatter"?ce(s,a.id,d):`legacy metadata/filename mismatch: epic '${s}' id does not match its filename`),l.push({filename:s,fullPath:u,metadata:a,metadataSource:r.source})}const _=new Map,g=[];for(const s of l){const u=ne(s.filename,s.metadata);if(u===null){n.push(`${s.filename}: id resolution returned null`);continue}let y=u.id;const m=y,r=_.get(m)??0;r>0&&(y=`${m}-${r+1}`,i.push(`epic ID collision: '${m}' resolved to '${y}' for ${s.filename}`)),_.set(m,r+1),g.push({candidate:s,finalId:y,idSource:s.metadataSource==="legacy-metadata"&&s.metadata.id?"legacy-metadata":u.source})}const p=new Map,b=ue(t,e);if(e.isDirectory(b)){const s=[...e.readdir(b)].sort();for(const u of s){if(T(u)||!u.endsWith(".md"))continue;const y=e.join(b,u);if(e.isDirectory(y))continue;const m=de(u);if(!m)continue;const r=e.readFile(y),a=N(r,u);i.push(...a.warnings);const d=a.metadata;d.id&&C(d.id,m.id)&&i.push(a.source==="frontmatter"?ae(u,d.id,m.id):`legacy metadata/filename mismatch: story '${u}' id does not match its filename`);const h=d.epic_id??m.epic_id_hint;if(!h){i.push(`story '${u}' has no epic_id (frontmatter or filename pattern)`);continue}const S=Object.freeze({id:m.id,epic_id:h,filename:u,path:y,...d.title?{title:d.title}:{},...d.status?{status:d.status}:{},...d.depends_on?{depends_on:d.depends_on}:{},...d.files_to_modify?{files_to_modify:d.files_to_modify}:{}});p.has(h)||p.set(h,[]),p.get(h).push(S)}}for(const s of g){const u=p.get(s.finalId)??[],y=Object.freeze({id:s.finalId,filename:s.candidate.filename,path:s.candidate.fullPath,...s.candidate.metadata.title?{title:s.candidate.metadata.title}:{},...s.candidate.metadata.status?{status:s.candidate.metadata.status}:{},...s.candidate.metadata.total_stories!==void 0?{total_stories:s.candidate.metadata.total_stories}:{},...s.candidate.metadata.depends_on?{depends_on:s.candidate.metadata.depends_on}:{},source:"discovered",discovered_at:o,stories:Object.freeze([...u]),t:s.idSource,e:s.candidate.metadataSource});c.push(y)}const $=new Set(c.map(s=>s.id));for(const[s,u]of p.entries())if(!$.has(s))for(const y of u)i.push(`orphan story '${y.filename}' references unknown epic '${s}'`);return Object.freeze({epics:Object.freeze(c),warnings:Object.freeze(i),skipped:Object.freeze(n)})}function M(t){const e=fe(t);return e==="done"||e==="completed"||e==="merged"}function A(t){return M(t)}function fe(t){return typeof t=="string"?t.trim().toLowerCase().replace(/[_\s]+/g,"-"):""}function E(t){return!!t&&typeof t=="object"&&!Array.isArray(t)}function w(t){throw new Error(`epic-aggregate-writer:${t}`)}function le(t){const e=[],i=new Set;for(const n of t)(typeof n!="string"||n.length===0)&&w("epic_aggregate_story_ref_invalid"),i.has(n)&&w("epic_aggregate_story_ref_duplicate"),i.add(n),e.push(n);return e}function pe(t,e,i){const n=i[e];return n===void 0&&w("epic_aggregate_story_missing"),E(n)||w("epic_aggregate_story_not_object"),n.id!==e&&w("epic_aggregate_story_id_conflict"),n.epic_id!==t&&w("epic_aggregate_story_epic_conflict"),n}function me(t,e,i,n,c){const o=e[t];E(o)||w("epic_aggregate_epic_not_object"),o.id!==t&&w("epic_aggregate_epic_id_conflict"),Array.isArray(o.stories)||w("epic_aggregate_story_list_not_array");const f=le(o.stories),l=f.length,_=f.filter(u=>M(pe(t,u,i).status)).length,g=c?.total_stories,p=c?.e==="legacy-metadata"&&A(o.status),b=p?g??l:l>0?l:g??0,$=p||l===0&&b>0&&A(o.status)?b:_,s=l>0&&_===l;A(o.status)&&!p&&!s&&l>0&&w("epic_aggregate_terminal_incomplete_conflict"),o.total_stories=b,o.completed_stories=$,!A(o.status)&&s&&(o.status="done",(o.completed_at===void 0||o.completed_at===null)&&(o.completed_at=n))}function ye(t,e,i){const n=t??{},c=E(n.epics)?n.epics:{},o=E(n.stories)?n.stories:{},f=structuredClone(c),l=structuredClone(o),_=[],g=[],p=new Set,b=new Map;let $=0;const s=i?.forceOverwrite===!0,u=i?.nowIso??new Date().toISOString(),y=new Set,m=new Map;for(const r of e.epics){if(!r.id||y.has(r.id))throw new Error("discovery_epic_identity_conflict");y.add(r.id),b.set(r.id,r);const a=f[r.id];if(a!==void 0&&(!E(a)||a.id!==void 0&&a.id!==r.id))throw new Error("discovery_epic_identity_conflict");for(const d of r.stories){if(!d.id||d.epic_id!==r.id||m.has(d.id))throw new Error("discovery_story_identity_conflict");m.set(d.id,r.id);const h=l[d.id];if(h!==void 0&&(!E(h)||h.id!==void 0&&h.id!==d.id||h.epic_id!==void 0&&h.epic_id!==r.id))throw new Error("discovery_story_identity_conflict")}}for(const r of e.epics){const a=f[r.id],d=a;if(d&&(d.branch_mode==="epic"||typeof d.execution_branch=="string")){g.push({id:r.id,reason:"epic Git lifecycle preserved"});continue}if(a&&a.source==="create-epic"&&!s){g.push({id:r.id,reason:"create-epic source preserved (defense)"});continue}if(a){const S=a;S.depends_on=r.depends_on??[];const v=Array.isArray(S.stories)?S.stories.filter(W=>typeof W=="string"):[];S.stories=[...new Set([...v,...r.stories.map(W=>W.id)])],r.title!==void 0&&(S.title=r.title),a.source!=="create-epic"&&p.add(r.id);continue}const h={id:r.id,title:r.title??"",status:r.status??"planning",source:"discovered",discovered_at:u,merged_at:u,stories:r.stories.map(S=>S.id),depends_on:r.depends_on??[]};f[r.id]=h,p.add(r.id),_.push(r.id)}for(const r of e.epics)for(const a of r.stories){const d=l[a.id],h=f[r.id];if(d&&h&&(h.branch_mode==="epic"||typeof h.execution_branch=="string")){g.push({id:a.id,reason:"story Git lifecycle preserved"});continue}if(d&&d.source==="create-epic"&&!s){g.push({id:a.id,reason:"create-epic source preserved (defense, story)"});continue}if(d){const v=d;a.title!==void 0&&(v.title=a.title),v.depends_on=a.depends_on??[],v.files_to_modify=a.files_to_modify??[];continue}const S={id:a.id,epic_id:a.epic_id,title:a.title??"",status:"backlog",source:"discovered",discovered_at:u,depends_on:a.depends_on??[],files_to_modify:a.files_to_modify??[]};l[a.id]=S,$++}for(const r of p)me(r,f,l,u,b.get(r));return n.epics=f,n.stories=l,{state:n,added:Object.freeze([..._]),skipped:Object.freeze([...g]),storiesAdded:$}}export{O as EPIC_FILE_PATTERNS,ge as GENERATED_NAMING_CONTRACT,D as STORY_FILE_PATTERNS,he as buildDefaultDiscoveryFs,_e as discoverEpicsFromDir,ne as getEpicIdFromDescriptor,ye as mergeDiscoveredIntoState};
|
|
1
|
+
/**
|
|
2
|
+
* @license FSL-1.1
|
|
3
|
+
* Copyright (c) 2026 OrNexus AI
|
|
4
|
+
*
|
|
5
|
+
* This file is part of Neocortex CLI, licensed under the
|
|
6
|
+
* Functional Source License, Version 1.1 (FSL-1.1).
|
|
7
|
+
*
|
|
8
|
+
* Change Date: February 20, 2029
|
|
9
|
+
* Change License: MIT
|
|
10
|
+
*
|
|
11
|
+
* See the LICENSE file in the project root for full license text.
|
|
12
|
+
*/
|
|
13
|
+
// =====================================================================
|
|
14
|
+
// Pattern arrays (2 readonly arrays per AC3)
|
|
15
|
+
// =====================================================================
|
|
16
|
+
/**
|
|
17
|
+
* Canonical naming contract for files generated by `*create-epic`.
|
|
18
|
+
*
|
|
19
|
+
* Discovery keeps accepting older forms for compatibility, but new generated
|
|
20
|
+
* files must use only the canonical values below.
|
|
21
|
+
*/
|
|
22
|
+
export const GENERATED_NAMING_CONTRACT = Object.freeze({
|
|
23
|
+
epicId: 'P{number}',
|
|
24
|
+
epicFilename: 'docs/epics/epic-P{number}.md',
|
|
25
|
+
storyId: 'P{number}.NN',
|
|
26
|
+
storyFilename: 'docs/stories/P{number}.NN.story.md',
|
|
27
|
+
acceptedOnly: Object.freeze({
|
|
28
|
+
epicFilenames: Object.freeze([
|
|
29
|
+
'docs/epics/epic-{number}.md',
|
|
30
|
+
'docs/epics/EPIC-{number}-{slug}.md',
|
|
31
|
+
'docs/epics/EPIC-{number}_{slug}.md',
|
|
32
|
+
'docs/epics/epic-{slug}.md',
|
|
33
|
+
]),
|
|
34
|
+
storyFilenames: Object.freeze([
|
|
35
|
+
'docs/stories/{number}.NN.story.md',
|
|
36
|
+
'docs/stories/story-P{number}.NN.md',
|
|
37
|
+
'docs/stories/story-{number}.NN.md',
|
|
38
|
+
'docs/stories/EPIC-{number}.STORY-{number}.md',
|
|
39
|
+
]),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Three accepted epic filename conventions.
|
|
44
|
+
* Anchored regex (`^...$`) so partial matches are rejected.
|
|
45
|
+
*
|
|
46
|
+
* - `[0]` generated canonical: `epic-P119.md`
|
|
47
|
+
* accepted legacy: `epic-92.5.md`, `epic-277.md`
|
|
48
|
+
* - `[1]` accepted legacy: `EPIC-01-foundation.md`, `EPIC-12_setup.md`
|
|
49
|
+
* - `[2]` accepted legacy: `epic-customer-portal.md`, `epic-feature-x.md`
|
|
50
|
+
*
|
|
51
|
+
* Group 1 is the canonical ID (or slug) extracted from the filename.
|
|
52
|
+
*/
|
|
53
|
+
export const EPIC_FILE_PATTERNS = Object.freeze([
|
|
54
|
+
/^epic-(P?\d+(?:\.\d+)?)\.md$/i,
|
|
55
|
+
/^EPIC-(\d+)(?:[-_].*)?\.md$/,
|
|
56
|
+
/^epic-([a-z0-9][a-z0-9_-]*)\.md$/i,
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* Three accepted story filename conventions.
|
|
60
|
+
*
|
|
61
|
+
* - `[0]` generated canonical: `P119.01.story.md`
|
|
62
|
+
* accepted legacy: `01.02.story.md`
|
|
63
|
+
* - `[1]` accepted legacy: `story-P104.04.md`
|
|
64
|
+
* - `[2]` accepted legacy: `EPIC-01.STORY-02.md`
|
|
65
|
+
*
|
|
66
|
+
* Group 1 = story ID for `[0]` and `[1]`. For `[2]`, group 1 = epic id,
|
|
67
|
+
* group 2 = story local id (composite).
|
|
68
|
+
*
|
|
69
|
+
* Pattern[0] accepts both `P119.01.story.md` (P-prefix) and `01.02.story.md`
|
|
70
|
+
* (numeric only) -- the `P?` prefix is optional.
|
|
71
|
+
*/
|
|
72
|
+
export const STORY_FILE_PATTERNS = Object.freeze([
|
|
73
|
+
/^(P?\d+\.\d+(?:\.\d+)?)\.story\.md$/i,
|
|
74
|
+
/^story-(P?\d+\.\d+)\.md$/i,
|
|
75
|
+
/^EPIC-(\d+)\.STORY-(\d+)\.md$/,
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Subdirectory names that are skipped (not recursed) during scan.
|
|
79
|
+
* Operators use these conventions for archival/draft separation.
|
|
80
|
+
*/
|
|
81
|
+
const IGNORED_SUBDIRS = Object.freeze([
|
|
82
|
+
'.archive',
|
|
83
|
+
'_archive',
|
|
84
|
+
'_archives',
|
|
85
|
+
'_drafts',
|
|
86
|
+
'archive',
|
|
87
|
+
'archives',
|
|
88
|
+
'archived',
|
|
89
|
+
'drafts',
|
|
90
|
+
]);
|
|
91
|
+
// =====================================================================
|
|
92
|
+
// Helpers (internal, not exported)
|
|
93
|
+
// =====================================================================
|
|
94
|
+
/** Pre-compiled YAML frontmatter regex (top-of-file `---\n...\n---`). */
|
|
95
|
+
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
|
|
96
|
+
/** Field extractors -- trim + strip surrounding quotes if any. */
|
|
97
|
+
function parseField(block, key) {
|
|
98
|
+
const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'mi');
|
|
99
|
+
const match = block.match(re);
|
|
100
|
+
if (!match)
|
|
101
|
+
return undefined;
|
|
102
|
+
let value = match[1].trim();
|
|
103
|
+
// Strip wrapping quotes (single or double)
|
|
104
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
105
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
106
|
+
value = value.slice(1, -1);
|
|
107
|
+
}
|
|
108
|
+
return value || undefined;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Lightweight regex-only frontmatter parser.
|
|
112
|
+
* No js-yaml dependency: only the bounded discovery fields are extracted.
|
|
113
|
+
* Malformed frontmatter (no closing `---`, extra `:`) returns empty object.
|
|
114
|
+
*/
|
|
115
|
+
function parseFrontmatter(content) {
|
|
116
|
+
if (typeof content !== 'string' || content.length === 0)
|
|
117
|
+
return {};
|
|
118
|
+
const match = content.match(FRONTMATTER_RE);
|
|
119
|
+
if (!match)
|
|
120
|
+
return {};
|
|
121
|
+
const block = match[1];
|
|
122
|
+
return {
|
|
123
|
+
id: parseField(block, 'id'),
|
|
124
|
+
title: parseField(block, 'title'),
|
|
125
|
+
status: parseField(block, 'status'),
|
|
126
|
+
epic_id: parseField(block, 'epic_id'),
|
|
127
|
+
depends_on: parseDependencyField(block),
|
|
128
|
+
files_to_modify: parseStringListField(block, 'files_to_modify'),
|
|
129
|
+
total_stories: parseNonNegativeInteger(parseField(block, 'total_stories')),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const LEGACY_HEADER_MAX_LINES = 32;
|
|
133
|
+
const LEGACY_HEADER_MAX_CHARS = 4_096;
|
|
134
|
+
const LEGACY_FIELD_RE = /^\s*\*\*([^*\r\n]+?):\*\*\s*(.*?)\s*$/;
|
|
135
|
+
const LEGACY_KNOWN_STATUSES = new Set([
|
|
136
|
+
'backlog', 'pending', 'ready', 'planning', 'in-progress',
|
|
137
|
+
'done', 'completed', 'merged', 'cancelled', 'canceled', 'abandoned',
|
|
138
|
+
'draft', 'template', 'archived', 'branch-ready', 'diagnose', 'research',
|
|
139
|
+
'write-spec', 'create-tasks', 'implemented', 'refactor-suggest-done',
|
|
140
|
+
'property-tests-emitted', 'tests-validated', 'qa-gate-passed',
|
|
141
|
+
'memory-updated', 'committed', 'synced', 'pr-created', 'pr-reviewed', 'partial',
|
|
142
|
+
]);
|
|
143
|
+
function stripLegacyMarkup(raw) {
|
|
144
|
+
let value = raw.trim();
|
|
145
|
+
if (value.startsWith('`') && value.endsWith('`') && value.length >= 2) {
|
|
146
|
+
value = value.slice(1, -1).trim();
|
|
147
|
+
}
|
|
148
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
149
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
150
|
+
value = value.slice(1, -1).trim();
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function parseNonNegativeInteger(raw) {
|
|
155
|
+
if (raw === undefined || !/^\d{1,5}$/.test(raw.trim()))
|
|
156
|
+
return undefined;
|
|
157
|
+
const parsed = Number(raw);
|
|
158
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
159
|
+
}
|
|
160
|
+
function normalizeLegacyStatus(raw) {
|
|
161
|
+
const normalized = stripLegacyMarkup(raw).toLowerCase().replace(/[_\s]+/g, '-');
|
|
162
|
+
const aliases = {
|
|
163
|
+
planned: 'planning',
|
|
164
|
+
complete: 'completed',
|
|
165
|
+
closed: 'done',
|
|
166
|
+
finished: 'done',
|
|
167
|
+
};
|
|
168
|
+
const canonical = aliases[normalized] ?? normalized;
|
|
169
|
+
return LEGACY_KNOWN_STATUSES.has(canonical) ? canonical : undefined;
|
|
170
|
+
}
|
|
171
|
+
function normalizeLegacyLabel(raw) {
|
|
172
|
+
const label = raw.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
|
173
|
+
if (label === 'id' || label === 'epic id')
|
|
174
|
+
return 'id';
|
|
175
|
+
if (label === 'title' || label === 'titulo')
|
|
176
|
+
return 'title';
|
|
177
|
+
if (label === 'status')
|
|
178
|
+
return 'status';
|
|
179
|
+
if (label === 'total stories' || label === 'total de stories')
|
|
180
|
+
return 'total_stories';
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
function legacyHeadingTitle(lines) {
|
|
184
|
+
const heading = lines.find((line) => /^#\s+\S/.test(line));
|
|
185
|
+
if (!heading)
|
|
186
|
+
return undefined;
|
|
187
|
+
const value = heading.replace(/^#\s+/, '').trim();
|
|
188
|
+
const withoutPrefix = value.replace(/^(?:Epic|Story)\s+\S+\s*(?:(?:--?|:)|[\u2013\u2014])\s*/i, '');
|
|
189
|
+
return (withoutPrefix || value).slice(0, 200);
|
|
190
|
+
}
|
|
191
|
+
function selectLegacyValue(values, field, filename, warnings) {
|
|
192
|
+
const normalized = [...new Set(values.map(stripLegacyMarkup).filter(Boolean))];
|
|
193
|
+
if (normalized.length > 1) {
|
|
194
|
+
warnings.push(`legacy metadata ambiguous for '${field}' in '${filename}': conflicting values ignored`);
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
return normalized[0];
|
|
198
|
+
}
|
|
199
|
+
function parseLegacyMetadata(content, filename) {
|
|
200
|
+
const warnings = [];
|
|
201
|
+
const lines = [];
|
|
202
|
+
const boundedLines = content.slice(0, LEGACY_HEADER_MAX_CHARS).split(/\r?\n/, LEGACY_HEADER_MAX_LINES);
|
|
203
|
+
for (const line of boundedLines) {
|
|
204
|
+
if (lines.length > 0 && (/^\s*---\s*$/.test(line) || /^##\s+/.test(line)))
|
|
205
|
+
break;
|
|
206
|
+
lines.push(line);
|
|
207
|
+
}
|
|
208
|
+
const values = new Map();
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
const match = line.match(LEGACY_FIELD_RE);
|
|
211
|
+
if (!match)
|
|
212
|
+
continue;
|
|
213
|
+
const field = normalizeLegacyLabel(match[1]);
|
|
214
|
+
if (!field)
|
|
215
|
+
continue;
|
|
216
|
+
const current = values.get(field) ?? [];
|
|
217
|
+
current.push(match[2]);
|
|
218
|
+
values.set(field, current);
|
|
219
|
+
}
|
|
220
|
+
const id = selectLegacyValue(values.get('id') ?? [], 'id', filename, warnings);
|
|
221
|
+
const explicitTitle = selectLegacyValue(values.get('title') ?? [], 'title', filename, warnings);
|
|
222
|
+
const rawStatus = selectLegacyValue(values.get('status') ?? [], 'status', filename, warnings);
|
|
223
|
+
const rawTotalStories = selectLegacyValue(values.get('total_stories') ?? [], 'total_stories', filename, warnings);
|
|
224
|
+
const status = rawStatus === undefined ? undefined : normalizeLegacyStatus(rawStatus);
|
|
225
|
+
if (rawStatus !== undefined && status === undefined) {
|
|
226
|
+
warnings.push(`legacy metadata status unrecognized in '${filename}': value ignored`);
|
|
227
|
+
}
|
|
228
|
+
const totalStories = parseNonNegativeInteger(rawTotalStories);
|
|
229
|
+
if (rawTotalStories !== undefined && totalStories === undefined) {
|
|
230
|
+
warnings.push(`legacy metadata total_stories invalid in '${filename}': value ignored`);
|
|
231
|
+
}
|
|
232
|
+
const safeId = id && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id) ? id : undefined;
|
|
233
|
+
if (id !== undefined && safeId === undefined) {
|
|
234
|
+
warnings.push(`legacy metadata id invalid in '${filename}': value ignored`);
|
|
235
|
+
}
|
|
236
|
+
const headingTitle = legacyHeadingTitle(lines);
|
|
237
|
+
const title = explicitTitle ?? headingTitle;
|
|
238
|
+
const metadata = {
|
|
239
|
+
...(safeId ? { id: safeId } : {}),
|
|
240
|
+
...(title ? { title: title.slice(0, 200) } : {}),
|
|
241
|
+
...(status ? { status } : {}),
|
|
242
|
+
...(totalStories !== undefined ? { total_stories: totalStories } : {}),
|
|
243
|
+
};
|
|
244
|
+
return { metadata, source: 'legacy-metadata', warnings: Object.freeze(warnings) };
|
|
245
|
+
}
|
|
246
|
+
function safeDiagnosticFilename(filename) {
|
|
247
|
+
return /^[A-Za-z0-9._-]{1,160}$/.test(filename) ? filename : 'markdown-target.md';
|
|
248
|
+
}
|
|
249
|
+
function utf8ByteLength(value) {
|
|
250
|
+
return new TextEncoder().encode(value).length;
|
|
251
|
+
}
|
|
252
|
+
function hasUnsupportedFrontmatterShape(block) {
|
|
253
|
+
let listAllowed = false;
|
|
254
|
+
for (const rawLine of block.split(/\r?\n/)) {
|
|
255
|
+
if (!rawLine.trim() || rawLine.trimStart().startsWith('#'))
|
|
256
|
+
continue;
|
|
257
|
+
const listItem = rawLine.match(/^\s+-\s+(.+?)\s*$/);
|
|
258
|
+
if (listItem) {
|
|
259
|
+
if (!listAllowed)
|
|
260
|
+
return true;
|
|
261
|
+
const value = listItem[1].trim();
|
|
262
|
+
if ((value.startsWith('"') && !value.endsWith('"')) ||
|
|
263
|
+
(value.startsWith("'") && !value.endsWith("'")))
|
|
264
|
+
return true;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (/^\s+/.test(rawLine))
|
|
268
|
+
return true;
|
|
269
|
+
const field = rawLine.match(/^[A-Za-z0-9_-]+:\s*(.*?)\s*$/);
|
|
270
|
+
if (!field)
|
|
271
|
+
return true;
|
|
272
|
+
const value = field[1].trim();
|
|
273
|
+
listAllowed = value.length === 0;
|
|
274
|
+
if ((value.startsWith('"') && !value.endsWith('"')) ||
|
|
275
|
+
(value.startsWith("'") && !value.endsWith("'")))
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
function frontmatterDiagnosticWarnings(content, filename, metadata) {
|
|
281
|
+
const safeFilename = safeDiagnosticFilename(filename);
|
|
282
|
+
const match = content.match(FRONTMATTER_RE);
|
|
283
|
+
if (!match) {
|
|
284
|
+
return Object.freeze([
|
|
285
|
+
`metadata-frontmatter reason=frontmatter-malformed file='${safeFilename}': repair the bounded leading YAML envelope; source content omitted`,
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
const block = match[1];
|
|
289
|
+
if (utf8ByteLength(block) > 8 * 1024 || block.split(/\r?\n/).length > 128) {
|
|
290
|
+
return Object.freeze([
|
|
291
|
+
`metadata-frontmatter reason=frontmatter-oversized file='${safeFilename}': reduce the leading YAML envelope; source content omitted`,
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
if (hasUnsupportedFrontmatterShape(block) ||
|
|
295
|
+
(block.trim().length > 0 && Object.keys(metadata).length === 0)) {
|
|
296
|
+
return Object.freeze([
|
|
297
|
+
`metadata-frontmatter reason=frontmatter-unsupported-value file='${safeFilename}': use bounded scalar fields or string lists; source content omitted`,
|
|
298
|
+
]);
|
|
299
|
+
}
|
|
300
|
+
return Object.freeze([]);
|
|
301
|
+
}
|
|
302
|
+
function parseDescriptorMetadata(content, filename) {
|
|
303
|
+
if (/^---(?:\s*\r?\n|\s*$)/.test(content)) {
|
|
304
|
+
const metadata = parseFrontmatter(content);
|
|
305
|
+
return {
|
|
306
|
+
metadata,
|
|
307
|
+
source: 'frontmatter',
|
|
308
|
+
warnings: frontmatterDiagnosticWarnings(content, filename, metadata),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (typeof content !== 'string' || content.length === 0) {
|
|
312
|
+
return { metadata: {}, source: 'none', warnings: Object.freeze([]) };
|
|
313
|
+
}
|
|
314
|
+
return parseLegacyMetadata(content, filename);
|
|
315
|
+
}
|
|
316
|
+
function normalizeDependencyToken(raw) {
|
|
317
|
+
let value = raw.trim();
|
|
318
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
319
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
320
|
+
value = value.slice(1, -1).trim();
|
|
321
|
+
}
|
|
322
|
+
if (!value)
|
|
323
|
+
return undefined;
|
|
324
|
+
if (/^(null|undefined|true|false)$/i.test(value))
|
|
325
|
+
return undefined;
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
function parseDependencyField(block) {
|
|
329
|
+
return parseStringListField(block, 'depends_on');
|
|
330
|
+
}
|
|
331
|
+
function parseStringListField(block, fieldName) {
|
|
332
|
+
const escapedField = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
333
|
+
const inlineMatch = block.match(new RegExp(`^${escapedField}:[ \\t]*(.+?)[ \\t]*$`, 'mi'));
|
|
334
|
+
const inline = inlineMatch?.[1]?.trim();
|
|
335
|
+
if (inline !== undefined) {
|
|
336
|
+
const rawItems = inline.startsWith('[') && inline.endsWith(']')
|
|
337
|
+
? inline.slice(1, -1).split(',')
|
|
338
|
+
: [inline];
|
|
339
|
+
const items = rawItems
|
|
340
|
+
.map(normalizeDependencyToken)
|
|
341
|
+
.filter((d) => typeof d === 'string' && d.length > 0);
|
|
342
|
+
return items.length > 0 ? Object.freeze(items) : undefined;
|
|
343
|
+
}
|
|
344
|
+
const lines = block.split(/\r?\n/);
|
|
345
|
+
const items = [];
|
|
346
|
+
let inListBlock = false;
|
|
347
|
+
for (const line of lines) {
|
|
348
|
+
if (!inListBlock) {
|
|
349
|
+
if (new RegExp(`^${escapedField}:\\s*$`, 'i').test(line.trim())) {
|
|
350
|
+
inListBlock = true;
|
|
351
|
+
}
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (/^[A-Za-z0-9_-]+:\s*/.test(line))
|
|
355
|
+
break;
|
|
356
|
+
const match = line.match(/^\s*-\s*(.+?)\s*$/);
|
|
357
|
+
if (!match)
|
|
358
|
+
continue;
|
|
359
|
+
const value = normalizeDependencyToken(match[1]);
|
|
360
|
+
if (value)
|
|
361
|
+
items.push(value);
|
|
362
|
+
}
|
|
363
|
+
return items.length > 0 ? Object.freeze(items) : undefined;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Test if a basename should be ignored (drafts/hidden).
|
|
367
|
+
* Anchored on prefix only (`_*`, `.*`).
|
|
368
|
+
*/
|
|
369
|
+
function isIgnoredFile(basename) {
|
|
370
|
+
return basename.startsWith('_') || basename.startsWith('.');
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Test if a subdirectory name should be skipped.
|
|
374
|
+
*/
|
|
375
|
+
function isIgnoredSubdir(name) {
|
|
376
|
+
return IGNORED_SUBDIRS.includes(name);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Default filesystem adapter wrapping `node:fs` synchronous APIs.
|
|
380
|
+
* Lazy-loaded via dynamic require so this module is safe to import in
|
|
381
|
+
* non-Node environments (browser bundles tree-shake unused calls).
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```ts
|
|
385
|
+
* const fs = await buildDefaultDiscoveryFs();
|
|
386
|
+
* const result = discoverEpicsFromDir(dir, fs);
|
|
387
|
+
* ```
|
|
388
|
+
*/
|
|
389
|
+
export async function buildDefaultDiscoveryFs() {
|
|
390
|
+
const fsModule = await import('node:fs');
|
|
391
|
+
const pathModule = await import('node:path');
|
|
392
|
+
return {
|
|
393
|
+
isDirectory(p) {
|
|
394
|
+
try {
|
|
395
|
+
return fsModule.statSync(p).isDirectory();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
readdir(p) {
|
|
402
|
+
try {
|
|
403
|
+
return fsModule.readdirSync(p);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return [];
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
readFile(p) {
|
|
410
|
+
try {
|
|
411
|
+
return fsModule.readFileSync(p, 'utf8');
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return '';
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
join(...parts) {
|
|
418
|
+
try {
|
|
419
|
+
return pathModule.join(...parts);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return parts.join('/');
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// =====================================================================
|
|
428
|
+
// Public API
|
|
429
|
+
// =====================================================================
|
|
430
|
+
/**
|
|
431
|
+
* Resolve canonical epic ID from filename + frontmatter.
|
|
432
|
+
*
|
|
433
|
+
* Priority (highest to lowest):
|
|
434
|
+
* 1. Frontmatter `id:` field (operator-authoritative)
|
|
435
|
+
* 2. `EPIC_FILE_PATTERNS[0]` canonical (numeric or P-prefix)
|
|
436
|
+
* 3. `EPIC_FILE_PATTERNS[1]` uppercase numeric
|
|
437
|
+
* 4. `EPIC_FILE_PATTERNS[2]` alpha-slug fallback
|
|
438
|
+
*
|
|
439
|
+
* Returns `null` if no pattern matches AND no frontmatter id.
|
|
440
|
+
*
|
|
441
|
+
* @param filename basename only (e.g., `epic-P119.md`)
|
|
442
|
+
* @param frontmatter parsed frontmatter (may have `id?: string`)
|
|
443
|
+
*/
|
|
444
|
+
export function getEpicIdFromDescriptor(filename, frontmatter) {
|
|
445
|
+
// Priority 1: frontmatter id (only if pattern at least matches the
|
|
446
|
+
// filename shape -- prevents arbitrary files claiming to be epics)
|
|
447
|
+
let patternMatched = false;
|
|
448
|
+
let patternId = null;
|
|
449
|
+
let patternSource = null;
|
|
450
|
+
for (let i = 0; i < EPIC_FILE_PATTERNS.length; i++) {
|
|
451
|
+
const re = EPIC_FILE_PATTERNS[i];
|
|
452
|
+
const match = filename.match(re);
|
|
453
|
+
if (match) {
|
|
454
|
+
patternMatched = true;
|
|
455
|
+
patternId = match[1];
|
|
456
|
+
patternSource = i === 2 ? 'filename-slug' : 'filename-canonical';
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!patternMatched) {
|
|
461
|
+
// Filename did NOT match any epic pattern -- not an epic at all.
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
// Frontmatter id wins if present
|
|
465
|
+
if (frontmatter.id && frontmatter.id.length > 0) {
|
|
466
|
+
return { id: frontmatter.id, source: 'frontmatter' };
|
|
467
|
+
}
|
|
468
|
+
return { id: patternId, source: patternSource };
|
|
469
|
+
}
|
|
470
|
+
function getEpicIdFromFilename(filename) {
|
|
471
|
+
for (const re of EPIC_FILE_PATTERNS) {
|
|
472
|
+
const match = filename.match(re);
|
|
473
|
+
if (match)
|
|
474
|
+
return match[1];
|
|
475
|
+
}
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
function normalizeGeneratedComparableId(id) {
|
|
479
|
+
const trimmed = id.trim();
|
|
480
|
+
if (/^\d+(?:\.\d+)*$/i.test(trimmed))
|
|
481
|
+
return `P${trimmed}`.toUpperCase();
|
|
482
|
+
return trimmed.toUpperCase();
|
|
483
|
+
}
|
|
484
|
+
function canonicalEpicFilenameFor(id) {
|
|
485
|
+
const normalized = normalizeGeneratedComparableId(id);
|
|
486
|
+
return /^P\d+(?:\.\d+)?$/i.test(normalized)
|
|
487
|
+
? `epic-${normalized}.md`
|
|
488
|
+
: `epic-${id}.md`;
|
|
489
|
+
}
|
|
490
|
+
function canonicalStoryFilenameFor(id) {
|
|
491
|
+
const normalized = normalizeGeneratedComparableId(id);
|
|
492
|
+
return /^P\d+\.\d+(?:\.\d+)?$/i.test(normalized)
|
|
493
|
+
? `${normalized}.story.md`
|
|
494
|
+
: `${id}.story.md`;
|
|
495
|
+
}
|
|
496
|
+
function idsDiffer(frontmatterId, filenameId) {
|
|
497
|
+
if (!frontmatterId || !filenameId)
|
|
498
|
+
return false;
|
|
499
|
+
return normalizeGeneratedComparableId(frontmatterId) !== normalizeGeneratedComparableId(filenameId);
|
|
500
|
+
}
|
|
501
|
+
function epicMismatchWarning(filename, frontmatterId, filenameId) {
|
|
502
|
+
return `frontmatter/filename mismatch: epic '${filename}' has frontmatter id '${frontmatterId}' but filename implies '${filenameId}'. Repair by using generated canonical '${canonicalEpicFilenameFor(frontmatterId)}' or changing frontmatter id to '${filenameId}'.`;
|
|
503
|
+
}
|
|
504
|
+
function storyMismatchWarning(filename, frontmatterId, filenameId) {
|
|
505
|
+
return `frontmatter/filename mismatch: story '${filename}' has frontmatter id '${frontmatterId}' but filename implies '${filenameId}'. Repair by using generated canonical '${canonicalStoryFilenameFor(frontmatterId)}' or changing frontmatter id to '${filenameId}'.`;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Match a story filename and extract its canonical ID.
|
|
509
|
+
* Returns null if no story pattern matches.
|
|
510
|
+
*/
|
|
511
|
+
function matchStoryFilename(filename) {
|
|
512
|
+
// Pattern [0] canonical: P119.01.story.md
|
|
513
|
+
let m = filename.match(STORY_FILE_PATTERNS[0]);
|
|
514
|
+
if (m) {
|
|
515
|
+
const id = m[1];
|
|
516
|
+
// Hint: epic_id is the prefix before the dot (P119.01 -> P119)
|
|
517
|
+
const dotIdx = id.indexOf('.');
|
|
518
|
+
return {
|
|
519
|
+
id,
|
|
520
|
+
epic_id_hint: dotIdx > 0 ? id.slice(0, dotIdx) : null,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// Pattern [1] legacy: story-P104.04.md
|
|
524
|
+
m = filename.match(STORY_FILE_PATTERNS[1]);
|
|
525
|
+
if (m) {
|
|
526
|
+
const id = m[1];
|
|
527
|
+
const dotIdx = id.indexOf('.');
|
|
528
|
+
return {
|
|
529
|
+
id,
|
|
530
|
+
epic_id_hint: dotIdx > 0 ? id.slice(0, dotIdx) : null,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
// Pattern [2] uppercase: EPIC-01.STORY-02.md
|
|
534
|
+
m = filename.match(STORY_FILE_PATTERNS[2]);
|
|
535
|
+
if (m) {
|
|
536
|
+
const epicNum = m[1];
|
|
537
|
+
const storyNum = m[2];
|
|
538
|
+
return {
|
|
539
|
+
id: `${epicNum}.${storyNum}`,
|
|
540
|
+
epic_id_hint: epicNum,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Locate the stories directory adjacent to the epics directory.
|
|
547
|
+
* Convention: `<dir>/../stories/`. Returns the path string regardless of
|
|
548
|
+
* existence (caller checks via fs adapter).
|
|
549
|
+
*/
|
|
550
|
+
function inferStoriesDir(epicsDir, fs) {
|
|
551
|
+
return fs.join(epicsDir, '..', 'stories');
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Scan `dir` for epic markdown files and return discovered descriptors.
|
|
555
|
+
*
|
|
556
|
+
* Behavior:
|
|
557
|
+
* - Non-recursive scan of `dir/*.md`
|
|
558
|
+
* - Sibling stories directory at `<dir>/../stories/*.story.md` (if exists)
|
|
559
|
+
* - Files with prefix `_` or `.` are skipped (drafts, hidden)
|
|
560
|
+
* - Subdirectories listed in `IGNORED_SUBDIRS` are not recursed
|
|
561
|
+
* - Frontmatter parser extracts `id`, `title`, `status`, `epic_id`
|
|
562
|
+
* - ID derivation: frontmatter > canonical regex > slug fallback
|
|
563
|
+
* - Slug collision: 2nd file with same canonical ID gets `-2`, `-3` suffix + warning
|
|
564
|
+
* - Story-to-epic linking: prefer frontmatter `epic_id` field, fallback filename pattern
|
|
565
|
+
* - Default fs adapter wraps `node:fs` synchronous methods (lazy-loaded)
|
|
566
|
+
*
|
|
567
|
+
* @param dir absolute or relative path to the epics directory
|
|
568
|
+
* @param fs optional fs adapter; defaults to `node:fs` wrapper (lazy)
|
|
569
|
+
* @returns DiscoveryResult with epics, warnings, skipped arrays
|
|
570
|
+
*/
|
|
571
|
+
export function discoverEpicsFromDir(dir, fs) {
|
|
572
|
+
const warnings = [];
|
|
573
|
+
const skipped = [];
|
|
574
|
+
const epics = [];
|
|
575
|
+
const discoveredAt = new Date().toISOString();
|
|
576
|
+
// Defensive guard: dir must be a non-empty string
|
|
577
|
+
if (typeof dir !== 'string' || dir.length === 0) {
|
|
578
|
+
return Object.freeze({
|
|
579
|
+
epics: Object.freeze([]),
|
|
580
|
+
warnings: Object.freeze([]),
|
|
581
|
+
skipped: Object.freeze([]),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Skip discovery if dir doesn't exist (fail-open)
|
|
585
|
+
if (!fs.isDirectory(dir)) {
|
|
586
|
+
return Object.freeze({
|
|
587
|
+
epics: Object.freeze([]),
|
|
588
|
+
warnings: Object.freeze([]),
|
|
589
|
+
skipped: Object.freeze([]),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const entries = [...fs.readdir(dir)].sort();
|
|
593
|
+
const candidates = [];
|
|
594
|
+
for (const entry of entries) {
|
|
595
|
+
const fullPath = fs.join(dir, entry);
|
|
596
|
+
if (isIgnoredFile(entry)) {
|
|
597
|
+
skipped.push(`${entry}: ignored (draft or hidden prefix)`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (fs.isDirectory(fullPath)) {
|
|
601
|
+
if (isIgnoredSubdir(entry)) {
|
|
602
|
+
skipped.push(`${entry}: ignored subdirectory`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// Other subdirs: not recursed (P120 is non-recursive)
|
|
606
|
+
skipped.push(`${entry}: subdirectory not recursed`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (!entry.endsWith('.md')) {
|
|
610
|
+
// Not markdown -- silently skip
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
// Quick filename match check before parsing frontmatter
|
|
614
|
+
const matchesEpic = EPIC_FILE_PATTERNS.some((re) => re.test(entry));
|
|
615
|
+
if (!matchesEpic) {
|
|
616
|
+
skipped.push(`${entry}: did not match any EPIC_FILE_PATTERNS`);
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const content = fs.readFile(fullPath);
|
|
620
|
+
const parsed = parseDescriptorMetadata(content, entry);
|
|
621
|
+
warnings.push(...parsed.warnings);
|
|
622
|
+
const metadata = parsed.metadata;
|
|
623
|
+
const filenameId = getEpicIdFromFilename(entry);
|
|
624
|
+
if (metadata.id && idsDiffer(metadata.id, filenameId)) {
|
|
625
|
+
warnings.push(parsed.source === 'frontmatter'
|
|
626
|
+
? epicMismatchWarning(entry, metadata.id, filenameId)
|
|
627
|
+
: `legacy metadata/filename mismatch: epic '${entry}' id does not match its filename`);
|
|
628
|
+
}
|
|
629
|
+
candidates.push({ filename: entry, fullPath, metadata, metadataSource: parsed.source });
|
|
630
|
+
}
|
|
631
|
+
// ID resolution + collision tracking
|
|
632
|
+
const idCount = new Map();
|
|
633
|
+
const resolved = [];
|
|
634
|
+
for (const candidate of candidates) {
|
|
635
|
+
const idResult = getEpicIdFromDescriptor(candidate.filename, candidate.metadata);
|
|
636
|
+
if (idResult === null) {
|
|
637
|
+
// Should not happen since we pre-filtered, but defensive
|
|
638
|
+
skipped.push(`${candidate.filename}: id resolution returned null`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
let finalId = idResult.id;
|
|
642
|
+
// Collision suffix only for filename-slug source (frontmatter / canonical
|
|
643
|
+
// are already operator-deterministic; collisions there are user error)
|
|
644
|
+
const baseId = finalId;
|
|
645
|
+
const prevCount = idCount.get(baseId) ?? 0;
|
|
646
|
+
if (prevCount > 0) {
|
|
647
|
+
finalId = `${baseId}-${prevCount + 1}`;
|
|
648
|
+
warnings.push(`epic ID collision: '${baseId}' resolved to '${finalId}' for ${candidate.filename}`);
|
|
649
|
+
}
|
|
650
|
+
idCount.set(baseId, prevCount + 1);
|
|
651
|
+
resolved.push({
|
|
652
|
+
candidate,
|
|
653
|
+
finalId,
|
|
654
|
+
idSource: candidate.metadataSource === 'legacy-metadata' && candidate.metadata.id
|
|
655
|
+
? 'legacy-metadata'
|
|
656
|
+
: idResult.source,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// Discover stories from sibling stories/ dir
|
|
660
|
+
const storiesByEpic = new Map();
|
|
661
|
+
const storiesDir = inferStoriesDir(dir, fs);
|
|
662
|
+
if (fs.isDirectory(storiesDir)) {
|
|
663
|
+
const storyEntries = [...fs.readdir(storiesDir)].sort();
|
|
664
|
+
for (const entry of storyEntries) {
|
|
665
|
+
if (isIgnoredFile(entry))
|
|
666
|
+
continue;
|
|
667
|
+
if (!entry.endsWith('.md'))
|
|
668
|
+
continue;
|
|
669
|
+
const fullPath = fs.join(storiesDir, entry);
|
|
670
|
+
if (fs.isDirectory(fullPath))
|
|
671
|
+
continue;
|
|
672
|
+
const storyMatch = matchStoryFilename(entry);
|
|
673
|
+
if (!storyMatch)
|
|
674
|
+
continue;
|
|
675
|
+
const content = fs.readFile(fullPath);
|
|
676
|
+
const parsed = parseDescriptorMetadata(content, entry);
|
|
677
|
+
warnings.push(...parsed.warnings);
|
|
678
|
+
const fm = parsed.metadata;
|
|
679
|
+
if (fm.id && idsDiffer(fm.id, storyMatch.id)) {
|
|
680
|
+
warnings.push(parsed.source === 'frontmatter'
|
|
681
|
+
? storyMismatchWarning(entry, fm.id, storyMatch.id)
|
|
682
|
+
: `legacy metadata/filename mismatch: story '${entry}' id does not match its filename`);
|
|
683
|
+
}
|
|
684
|
+
// Link priority: frontmatter epic_id > filename pattern hint
|
|
685
|
+
const epicId = fm.epic_id ?? storyMatch.epic_id_hint;
|
|
686
|
+
if (!epicId) {
|
|
687
|
+
warnings.push(`story '${entry}' has no epic_id (frontmatter or filename pattern)`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const story = Object.freeze({
|
|
691
|
+
id: storyMatch.id,
|
|
692
|
+
epic_id: epicId,
|
|
693
|
+
filename: entry,
|
|
694
|
+
path: fullPath,
|
|
695
|
+
...(fm.title ? { title: fm.title } : {}),
|
|
696
|
+
...(fm.status ? { status: fm.status } : {}),
|
|
697
|
+
...(fm.depends_on ? { depends_on: fm.depends_on } : {}),
|
|
698
|
+
...(fm.files_to_modify ? { files_to_modify: fm.files_to_modify } : {}),
|
|
699
|
+
});
|
|
700
|
+
if (!storiesByEpic.has(epicId)) {
|
|
701
|
+
storiesByEpic.set(epicId, []);
|
|
702
|
+
}
|
|
703
|
+
storiesByEpic.get(epicId).push(story);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// Build final EpicDescriptor list
|
|
707
|
+
for (const r of resolved) {
|
|
708
|
+
const stories = storiesByEpic.get(r.finalId) ?? [];
|
|
709
|
+
const desc = Object.freeze({
|
|
710
|
+
id: r.finalId,
|
|
711
|
+
filename: r.candidate.filename,
|
|
712
|
+
path: r.candidate.fullPath,
|
|
713
|
+
...(r.candidate.metadata.title ? { title: r.candidate.metadata.title } : {}),
|
|
714
|
+
...(r.candidate.metadata.status ? { status: r.candidate.metadata.status } : {}),
|
|
715
|
+
...(r.candidate.metadata.total_stories !== undefined ? { total_stories: r.candidate.metadata.total_stories } : {}),
|
|
716
|
+
...(r.candidate.metadata.depends_on ? { depends_on: r.candidate.metadata.depends_on } : {}),
|
|
717
|
+
source: 'discovered',
|
|
718
|
+
discovered_at: discoveredAt,
|
|
719
|
+
stories: Object.freeze([...stories]),
|
|
720
|
+
_idSource: r.idSource,
|
|
721
|
+
_metadataSource: r.candidate.metadataSource,
|
|
722
|
+
});
|
|
723
|
+
epics.push(desc);
|
|
724
|
+
}
|
|
725
|
+
// Identify orphan stories (epic_id points to non-existent epic)
|
|
726
|
+
const knownEpicIds = new Set(epics.map((e) => e.id));
|
|
727
|
+
for (const [epicId, stories] of storiesByEpic.entries()) {
|
|
728
|
+
if (!knownEpicIds.has(epicId)) {
|
|
729
|
+
for (const s of stories) {
|
|
730
|
+
warnings.push(`orphan story '${s.filename}' references unknown epic '${epicId}'`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return Object.freeze({
|
|
735
|
+
epics: Object.freeze(epics),
|
|
736
|
+
warnings: Object.freeze(warnings),
|
|
737
|
+
skipped: Object.freeze(skipped),
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function isDoneLikeStoryStatus(status) {
|
|
741
|
+
const normalized = normalizePublicStatus(status);
|
|
742
|
+
return normalized === 'done' || normalized === 'completed' || normalized === 'merged';
|
|
743
|
+
}
|
|
744
|
+
function isTerminalEpicStatus(status) {
|
|
745
|
+
return isDoneLikeStoryStatus(status);
|
|
746
|
+
}
|
|
747
|
+
function normalizePublicStatus(status) {
|
|
748
|
+
return typeof status === 'string' ? status.trim().toLowerCase().replace(/[_\s]+/g, '-') : '';
|
|
749
|
+
}
|
|
750
|
+
function isRecord(value) {
|
|
751
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
752
|
+
}
|
|
753
|
+
function aggregateHardStop(reasonCode) {
|
|
754
|
+
throw new Error(`epic-aggregate-writer:${reasonCode}`);
|
|
755
|
+
}
|
|
756
|
+
function validateAggregateStoryRefs(storyRefs) {
|
|
757
|
+
const storyIds = [];
|
|
758
|
+
const seen = new Set();
|
|
759
|
+
for (const storyRef of storyRefs) {
|
|
760
|
+
if (typeof storyRef !== 'string' || storyRef.length === 0) {
|
|
761
|
+
aggregateHardStop('epic_aggregate_story_ref_invalid');
|
|
762
|
+
}
|
|
763
|
+
if (seen.has(storyRef))
|
|
764
|
+
aggregateHardStop('epic_aggregate_story_ref_duplicate');
|
|
765
|
+
seen.add(storyRef);
|
|
766
|
+
storyIds.push(storyRef);
|
|
767
|
+
}
|
|
768
|
+
return storyIds;
|
|
769
|
+
}
|
|
770
|
+
function validateAggregateListedStory(epicId, storyId, storiesMap) {
|
|
771
|
+
const story = storiesMap[storyId];
|
|
772
|
+
if (story === undefined)
|
|
773
|
+
aggregateHardStop('epic_aggregate_story_missing');
|
|
774
|
+
if (!isRecord(story))
|
|
775
|
+
aggregateHardStop('epic_aggregate_story_not_object');
|
|
776
|
+
if (story.id !== storyId)
|
|
777
|
+
aggregateHardStop('epic_aggregate_story_id_conflict');
|
|
778
|
+
if (story.epic_id !== epicId)
|
|
779
|
+
aggregateHardStop('epic_aggregate_story_epic_conflict');
|
|
780
|
+
return story;
|
|
781
|
+
}
|
|
782
|
+
function recomputeDiscoveredEpicAggregate(epicId, epicsMap, storiesMap, nowIso, descriptor) {
|
|
783
|
+
const epic = epicsMap[epicId];
|
|
784
|
+
if (!isRecord(epic))
|
|
785
|
+
aggregateHardStop('epic_aggregate_epic_not_object');
|
|
786
|
+
if (epic.id !== epicId)
|
|
787
|
+
aggregateHardStop('epic_aggregate_epic_id_conflict');
|
|
788
|
+
if (!Array.isArray(epic.stories))
|
|
789
|
+
aggregateHardStop('epic_aggregate_story_list_not_array');
|
|
790
|
+
const storyIds = validateAggregateStoryRefs(epic.stories);
|
|
791
|
+
const linkedStories = storyIds.length;
|
|
792
|
+
const linkedCompletedStories = storyIds.filter((storyId) => isDoneLikeStoryStatus(validateAggregateListedStory(epicId, storyId, storiesMap).status)).length;
|
|
793
|
+
const declaredTotalStories = descriptor?.total_stories;
|
|
794
|
+
const legacyTerminalMetadata = descriptor?._metadataSource === 'legacy-metadata' &&
|
|
795
|
+
isTerminalEpicStatus(epic.status);
|
|
796
|
+
const totalStories = legacyTerminalMetadata
|
|
797
|
+
? declaredTotalStories ?? linkedStories
|
|
798
|
+
: linkedStories > 0
|
|
799
|
+
? linkedStories
|
|
800
|
+
: declaredTotalStories ?? 0;
|
|
801
|
+
const completedStories = legacyTerminalMetadata ||
|
|
802
|
+
(linkedStories === 0 && totalStories > 0 && isTerminalEpicStatus(epic.status))
|
|
803
|
+
? totalStories
|
|
804
|
+
: linkedCompletedStories;
|
|
805
|
+
const allListedStoriesDoneLike = linkedStories > 0 && linkedCompletedStories === linkedStories;
|
|
806
|
+
if (isTerminalEpicStatus(epic.status) && !legacyTerminalMetadata &&
|
|
807
|
+
!allListedStoriesDoneLike && linkedStories > 0) {
|
|
808
|
+
aggregateHardStop('epic_aggregate_terminal_incomplete_conflict');
|
|
809
|
+
}
|
|
810
|
+
epic.total_stories = totalStories;
|
|
811
|
+
epic.completed_stories = completedStories;
|
|
812
|
+
if (!isTerminalEpicStatus(epic.status) && allListedStoriesDoneLike) {
|
|
813
|
+
epic.status = 'done';
|
|
814
|
+
if (epic.completed_at === undefined || epic.completed_at === null) {
|
|
815
|
+
epic.completed_at = nowIso;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Merge `DiscoveryResult` (from `discoverEpicsFromDir`) into a `state` object,
|
|
821
|
+
* preserving canonical `*create-epic`-registered entries unless `forceOverwrite`.
|
|
822
|
+
*
|
|
823
|
+
* **Critical defense**: the non-overwrite guard prevents discovered entries
|
|
824
|
+
* from clobbering valuable canonical registrations. Even with `forceOverwrite:
|
|
825
|
+
* true`, the function preserves the existing `source` field for audit trail.
|
|
826
|
+
*
|
|
827
|
+
* **Idempotency**: re-running merge with the same `discovered` + same `state`
|
|
828
|
+
* produces identical output (no duplicate stories, timestamps from first run
|
|
829
|
+
* preserved). Critical for `/loop` iteration which may re-invoke discovery
|
|
830
|
+
* between stories.
|
|
831
|
+
*
|
|
832
|
+
* **Mutation semantics**: merge work is staged on cloned maps and committed to
|
|
833
|
+
* the caller-owned container only after every identity and aggregate invariant
|
|
834
|
+
* passes. Validation failures therefore provide strong exception safety.
|
|
835
|
+
*
|
|
836
|
+
* @param state target state object (`null`/`undefined` ok -- treated as empty)
|
|
837
|
+
* @param discovered discovery result (epics + warnings + skipped)
|
|
838
|
+
* @param options optional behavior modifiers
|
|
839
|
+
* @returns `MergeResult` with mutated state + telemetry counters
|
|
840
|
+
*
|
|
841
|
+
* @example
|
|
842
|
+
* ```ts
|
|
843
|
+
* const result = mergeDiscoveredIntoState(state, discovered);
|
|
844
|
+
* if (result.skipped.length > 0) {
|
|
845
|
+
* console.warn('skipped:', result.skipped);
|
|
846
|
+
* }
|
|
847
|
+
* console.info(`merged ${result.added.length} epics, ${result.storiesAdded} stories`);
|
|
848
|
+
* ```
|
|
849
|
+
*/
|
|
850
|
+
export function mergeDiscoveredIntoState(state, discovered, options) {
|
|
851
|
+
// AC4: defensive null guard -- treat null/undefined state as empty containers.
|
|
852
|
+
// Existing maps remain untouched until the final two-field commit.
|
|
853
|
+
const safeState = state ?? {};
|
|
854
|
+
const currentEpics = isRecord(safeState.epics) ? safeState.epics : {};
|
|
855
|
+
const currentStories = isRecord(safeState.stories) ? safeState.stories : {};
|
|
856
|
+
const epicsMap = structuredClone(currentEpics);
|
|
857
|
+
const storiesMap = structuredClone(currentStories);
|
|
858
|
+
const added = [];
|
|
859
|
+
const skipped = [];
|
|
860
|
+
const aggregateEpicIds = new Set();
|
|
861
|
+
const aggregateDescriptors = new Map();
|
|
862
|
+
let storiesAdded = 0;
|
|
863
|
+
const forceOverwrite = options?.forceOverwrite === true;
|
|
864
|
+
const nowIso = options?.nowIso ?? new Date().toISOString();
|
|
865
|
+
// Track which epic IDs are part of THIS discovery batch (for orphan story detection)
|
|
866
|
+
const discoveredEpicIds = new Set();
|
|
867
|
+
const discoveredStoryOwners = new Map();
|
|
868
|
+
for (const epic of discovered.epics) {
|
|
869
|
+
if (!epic.id || discoveredEpicIds.has(epic.id)) {
|
|
870
|
+
throw new Error('discovery_epic_identity_conflict');
|
|
871
|
+
}
|
|
872
|
+
discoveredEpicIds.add(epic.id);
|
|
873
|
+
aggregateDescriptors.set(epic.id, epic);
|
|
874
|
+
const existingEpic = epicsMap[epic.id];
|
|
875
|
+
if (existingEpic !== undefined && (!isRecord(existingEpic) ||
|
|
876
|
+
existingEpic.id !== undefined && existingEpic.id !== epic.id)) {
|
|
877
|
+
throw new Error('discovery_epic_identity_conflict');
|
|
878
|
+
}
|
|
879
|
+
for (const story of epic.stories) {
|
|
880
|
+
if (!story.id || story.epic_id !== epic.id || discoveredStoryOwners.has(story.id)) {
|
|
881
|
+
throw new Error('discovery_story_identity_conflict');
|
|
882
|
+
}
|
|
883
|
+
discoveredStoryOwners.set(story.id, epic.id);
|
|
884
|
+
const existingStory = storiesMap[story.id];
|
|
885
|
+
if (existingStory !== undefined && (!isRecord(existingStory) ||
|
|
886
|
+
existingStory.id !== undefined && existingStory.id !== story.id ||
|
|
887
|
+
existingStory.epic_id !== undefined && existingStory.epic_id !== epic.id)) {
|
|
888
|
+
throw new Error('discovery_story_identity_conflict');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// -------------------------------------------------------------------
|
|
893
|
+
// Pass 1: merge epics
|
|
894
|
+
// -------------------------------------------------------------------
|
|
895
|
+
for (const epic of discovered.epics) {
|
|
896
|
+
const existing = epicsMap[epic.id];
|
|
897
|
+
// Filesystem discovery supplies descriptors, never physical Git authority.
|
|
898
|
+
// Preserve lifecycle state even when legacy records have no source marker.
|
|
899
|
+
const lifecycle = existing;
|
|
900
|
+
if (lifecycle && (lifecycle.branch_mode === 'epic' || typeof lifecycle.execution_branch === 'string')) {
|
|
901
|
+
skipped.push({ id: epic.id, reason: 'epic Git lifecycle preserved' });
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
// AC2: non-overwrite guard (default behavior)
|
|
905
|
+
if (existing && existing.source === 'create-epic' && !forceOverwrite) {
|
|
906
|
+
skipped.push({
|
|
907
|
+
id: epic.id,
|
|
908
|
+
reason: 'create-epic source preserved (defense)',
|
|
909
|
+
});
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
// Existing state owns lifecycle and unknown fields regardless of provenance.
|
|
913
|
+
// Discovery may only enrich bounded specification metadata.
|
|
914
|
+
if (existing) {
|
|
915
|
+
const existingRecord = existing;
|
|
916
|
+
existingRecord.depends_on = epic.depends_on ?? [];
|
|
917
|
+
const existingStoryIds = Array.isArray(existingRecord.stories)
|
|
918
|
+
? existingRecord.stories.filter((id) => typeof id === 'string')
|
|
919
|
+
: [];
|
|
920
|
+
existingRecord.stories = [...new Set([
|
|
921
|
+
...existingStoryIds,
|
|
922
|
+
...epic.stories.map((story) => story.id),
|
|
923
|
+
])];
|
|
924
|
+
if (epic.title !== undefined)
|
|
925
|
+
existingRecord.title = epic.title;
|
|
926
|
+
if (existing.source !== 'create-epic')
|
|
927
|
+
aggregateEpicIds.add(epic.id);
|
|
928
|
+
// Already merged previously -- skip without churning timestamps
|
|
929
|
+
// (NOT in skipped[] because this is normal idempotent flow, not a
|
|
930
|
+
// defense rejection)
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
// Build new entry (or overwrite when forceOverwrite is set)
|
|
934
|
+
const newEntry = {
|
|
935
|
+
id: epic.id,
|
|
936
|
+
title: epic.title ?? '',
|
|
937
|
+
status: epic.status ?? 'planning',
|
|
938
|
+
source: 'discovered',
|
|
939
|
+
discovered_at: nowIso,
|
|
940
|
+
merged_at: nowIso,
|
|
941
|
+
stories: epic.stories.map((s) => s.id),
|
|
942
|
+
depends_on: epic.depends_on ?? [],
|
|
943
|
+
};
|
|
944
|
+
epicsMap[epic.id] = newEntry;
|
|
945
|
+
aggregateEpicIds.add(epic.id);
|
|
946
|
+
added.push(epic.id);
|
|
947
|
+
}
|
|
948
|
+
// -------------------------------------------------------------------
|
|
949
|
+
// Pass 2: merge stories
|
|
950
|
+
// -------------------------------------------------------------------
|
|
951
|
+
for (const epic of discovered.epics) {
|
|
952
|
+
for (const story of epic.stories) {
|
|
953
|
+
const existing = storiesMap[story.id];
|
|
954
|
+
const owner = epicsMap[epic.id];
|
|
955
|
+
if (existing && owner && (owner.branch_mode === 'epic' || typeof owner.execution_branch === 'string')) {
|
|
956
|
+
skipped.push({ id: story.id, reason: 'story Git lifecycle preserved' });
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
// AC6: story-level non-overwrite guard
|
|
960
|
+
if (existing && existing.source === 'create-epic' && !forceOverwrite) {
|
|
961
|
+
skipped.push({
|
|
962
|
+
id: story.id,
|
|
963
|
+
reason: 'create-epic source preserved (defense, story)',
|
|
964
|
+
});
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
// Existing state owns workflow lifecycle even when provenance is absent
|
|
968
|
+
// or forceOverwrite is requested. Overlay specification metadata only.
|
|
969
|
+
if (existing) {
|
|
970
|
+
const existingRecord = existing;
|
|
971
|
+
if (story.title !== undefined)
|
|
972
|
+
existingRecord.title = story.title;
|
|
973
|
+
existingRecord.depends_on = story.depends_on ?? [];
|
|
974
|
+
existingRecord.files_to_modify = story.files_to_modify ?? [];
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const newStory = {
|
|
978
|
+
id: story.id,
|
|
979
|
+
epic_id: story.epic_id,
|
|
980
|
+
title: story.title ?? '',
|
|
981
|
+
// Filesystem frontmatter is specification, never workflow authority.
|
|
982
|
+
status: 'backlog',
|
|
983
|
+
source: 'discovered',
|
|
984
|
+
discovered_at: nowIso,
|
|
985
|
+
depends_on: story.depends_on ?? [],
|
|
986
|
+
files_to_modify: story.files_to_modify ?? [],
|
|
987
|
+
};
|
|
988
|
+
storiesMap[story.id] = newStory;
|
|
989
|
+
storiesAdded++;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// -------------------------------------------------------------------
|
|
993
|
+
// Pass 3: P189.04 aggregate writer invariants
|
|
994
|
+
// -------------------------------------------------------------------
|
|
995
|
+
for (const epicId of aggregateEpicIds) {
|
|
996
|
+
recomputeDiscoveredEpicAggregate(epicId, epicsMap, storiesMap, nowIso, aggregateDescriptors.get(epicId));
|
|
997
|
+
}
|
|
998
|
+
// Publish only after the full batch and all aggregate invariants pass.
|
|
999
|
+
safeState.epics = epicsMap;
|
|
1000
|
+
safeState.stories = storiesMap;
|
|
1001
|
+
return {
|
|
1002
|
+
state: safeState,
|
|
1003
|
+
added: Object.freeze([...added]),
|
|
1004
|
+
skipped: Object.freeze([...skipped]),
|
|
1005
|
+
storiesAdded,
|
|
1006
|
+
};
|
|
1007
|
+
}
|