@deftai/directive-core 0.60.0 → 0.61.1

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.
@@ -0,0 +1,248 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PROPOSED_DISCLAIMER_LINES, PROPOSED_STATUS_FILTER } from "../spec-authority/constants.js";
4
+ import { SCOPE_SUMMARY_NARRATIVES } from "./constants.js";
5
+ function readEdgeEndpoints(edge) {
6
+ if (typeof edge !== "object" || edge === null || Array.isArray(edge))
7
+ return ["", ""];
8
+ const e = edge;
9
+ return [String(e.from ?? e.source ?? "") || "", String(e.to ?? e.target ?? "") || ""];
10
+ }
11
+ function loadScopeVbriefs(folder) {
12
+ if (!existsSync(folder))
13
+ return [];
14
+ let entries;
15
+ try {
16
+ entries = readdirSync(folder)
17
+ .filter((n) => n.endsWith(".vbrief.json"))
18
+ .sort();
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ const out = [];
24
+ for (const name of entries) {
25
+ try {
26
+ const data = JSON.parse(readFileSync(join(folder, name), "utf8"));
27
+ const stem = name.endsWith(".vbrief.json") ? name.slice(0, -".vbrief.json".length) : name;
28
+ out.push([stem, data]);
29
+ }
30
+ catch {
31
+ /* skip */
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ function scopeId(stem, vbrief) {
37
+ const plan = vbrief.plan;
38
+ if (typeof plan === "object" && plan !== null && !Array.isArray(plan)) {
39
+ const planId = plan.id;
40
+ if (typeof planId === "string" && planId)
41
+ return planId;
42
+ }
43
+ return stem;
44
+ }
45
+ function crossScopeDepMap(scopes) {
46
+ const scopeIds = new Set(scopes.map(([stem, vb]) => scopeId(stem, vb)));
47
+ const depMap = {};
48
+ for (const [, vbrief] of scopes) {
49
+ const plan = vbrief.plan;
50
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
51
+ continue;
52
+ const edges = plan.edges;
53
+ if (!Array.isArray(edges))
54
+ continue;
55
+ for (const edge of edges) {
56
+ const [frm, to] = readEdgeEndpoints(edge);
57
+ if (frm && to && scopeIds.has(frm) && scopeIds.has(to)) {
58
+ if (!depMap[to])
59
+ depMap[to] = [];
60
+ depMap[to].push(frm);
61
+ }
62
+ }
63
+ }
64
+ return depMap;
65
+ }
66
+ function topoSortScopes(scopes, depMap) {
67
+ if (scopes.length === 0)
68
+ return [];
69
+ const idByIndex = scopes.map(([stem, vb]) => scopeId(stem, vb));
70
+ const idToIndex = new Map(idByIndex.map((sid, i) => [sid, i]));
71
+ const depths = {};
72
+ const depth = (sid, visited = null) => {
73
+ if (sid in depths)
74
+ return depths[sid] ?? 0;
75
+ const vis = visited ?? new Set();
76
+ if (vis.has(sid))
77
+ return 0;
78
+ vis.add(sid);
79
+ const deps = (depMap[sid] ?? []).filter((d) => idToIndex.has(d));
80
+ if (deps.length === 0) {
81
+ depths[sid] = 0;
82
+ return 0;
83
+ }
84
+ const result = Math.max(...deps.map((d) => depth(d, vis))) + 1;
85
+ depths[sid] = result;
86
+ return result;
87
+ };
88
+ for (const sid of idByIndex)
89
+ depth(sid);
90
+ const orderedIndices = [...idByIndex.keys()].sort((a, b) => (depths[idByIndex[a] ?? ""] ?? 0) - (depths[idByIndex[b] ?? ""] ?? 0) || a - b);
91
+ return orderedIndices.map((i) => scopes[i]);
92
+ }
93
+ function scopeSummaryNarrative(plan) {
94
+ const narratives = plan.narratives;
95
+ if (typeof narratives !== "object" || narratives === null || Array.isArray(narratives))
96
+ return "";
97
+ const narr = narratives;
98
+ for (const key of SCOPE_SUMMARY_NARRATIVES) {
99
+ const val = narr[key];
100
+ if (typeof val === "string" && val.trim())
101
+ return val.trim();
102
+ }
103
+ for (const val of Object.values(narr)) {
104
+ if (typeof val === "string" && val.trim())
105
+ return val.trim();
106
+ }
107
+ return "";
108
+ }
109
+ function splitAcceptance(value) {
110
+ if (Array.isArray(value)) {
111
+ return value.map((item) => String(item).trim()).filter((s) => s.length > 0);
112
+ }
113
+ if (typeof value !== "string")
114
+ return [];
115
+ const parts = [];
116
+ for (const line of value.split("\n")) {
117
+ let cleaned = line.trim();
118
+ if (!cleaned)
119
+ continue;
120
+ if (cleaned.startsWith("- ") || cleaned.startsWith("* "))
121
+ cleaned = cleaned.slice(2).trim();
122
+ if (cleaned)
123
+ parts.push(cleaned);
124
+ }
125
+ return parts;
126
+ }
127
+ function itemAcceptance(item) {
128
+ const narrative = item.narrative;
129
+ if (typeof narrative !== "object" || narrative === null || Array.isArray(narrative))
130
+ return [];
131
+ return splitAcceptance(narrative.Acceptance);
132
+ }
133
+ function renderScopeBlock(stem, vbrief) {
134
+ const plan = vbrief.plan;
135
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
136
+ return [];
137
+ const planObj = plan;
138
+ const title = String(planObj.title ?? stem);
139
+ const status = String(planObj.status ?? "");
140
+ let heading = `### ${stem}: ${title}`;
141
+ if (status)
142
+ heading += ` \`[${status}]\``;
143
+ const lines = [`${heading}\n`];
144
+ const summary = scopeSummaryNarrative(planObj);
145
+ if (summary)
146
+ lines.push(`${summary}\n`);
147
+ const narratives = planObj.narratives;
148
+ if (typeof narratives === "object" && narratives !== null && !Array.isArray(narratives)) {
149
+ const scopeAcceptance = splitAcceptance(narratives.Acceptance);
150
+ if (scopeAcceptance.length > 0) {
151
+ lines.push("**Scope Acceptance**:\n");
152
+ for (const criterion of scopeAcceptance)
153
+ lines.push(`- ${criterion}`);
154
+ lines.push("");
155
+ }
156
+ }
157
+ const items = planObj.items;
158
+ if (Array.isArray(items) && items.length > 0) {
159
+ lines.push("**Acceptance**:\n");
160
+ for (const item of items) {
161
+ if (typeof item !== "object" || item === null || Array.isArray(item))
162
+ continue;
163
+ const itemObj = item;
164
+ const itemTitle = String(itemObj.title ?? "Untitled");
165
+ const itemStatus = String(itemObj.status ?? "");
166
+ let bullet = `- ${itemTitle}`;
167
+ if (itemStatus)
168
+ bullet += ` \`[${itemStatus}]\``;
169
+ lines.push(bullet);
170
+ for (const criterion of itemAcceptance(itemObj)) {
171
+ if (criterion !== itemTitle)
172
+ lines.push(` - Acceptance: ${criterion}`);
173
+ }
174
+ }
175
+ lines.push("");
176
+ }
177
+ return lines;
178
+ }
179
+ const COMMITTED_BUCKETS = [
180
+ ["pending", "Accepted backlog (pending)"],
181
+ ["active", "Active"],
182
+ [
183
+ "completed",
184
+ "Completed",
185
+ (vb) => {
186
+ const plan = vb.plan;
187
+ return (typeof plan === "object" &&
188
+ plan !== null &&
189
+ !Array.isArray(plan) &&
190
+ plan.status === "completed");
191
+ },
192
+ ],
193
+ ];
194
+ function filterProposed(vb) {
195
+ const plan = vb.plan;
196
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
197
+ return true;
198
+ const status = String(plan.status ?? "proposed").toLowerCase();
199
+ return PROPOSED_STATUS_FILTER.has(status);
200
+ }
201
+ /** Build Scope outlook section per #2013 Wave 0 §4. */
202
+ export function buildScopeOutlookSection(vbriefDir, options = {}) {
203
+ const includeProposed = options.includeProposed ?? false;
204
+ const proposedLimit = options.proposedLimit ?? 0;
205
+ const buckets = [];
206
+ if (includeProposed) {
207
+ let proposed = loadScopeVbriefs(join(vbriefDir, "proposed")).filter(([, vb]) => filterProposed(vb));
208
+ if (proposedLimit > 0 && proposed.length > proposedLimit) {
209
+ const omitted = proposed.length - proposedLimit;
210
+ proposed = proposed.slice(0, proposedLimit);
211
+ buckets.push([
212
+ "proposed",
213
+ `Not yet accepted (proposed) — showing ${proposedLimit} of ${proposedLimit + omitted}`,
214
+ proposed,
215
+ ]);
216
+ }
217
+ else if (proposed.length > 0) {
218
+ buckets.push(["proposed", "Not yet accepted (proposed)", proposed]);
219
+ }
220
+ }
221
+ for (const [folder, heading, filter] of COMMITTED_BUCKETS) {
222
+ let scopes = loadScopeVbriefs(join(vbriefDir, folder));
223
+ if (filter)
224
+ scopes = scopes.filter(([, vb]) => filter(vb));
225
+ if (scopes.length > 0)
226
+ buckets.push([folder, heading, scopes]);
227
+ }
228
+ if (buckets.length === 0)
229
+ return [];
230
+ const lines = ["## Scope outlook\n"];
231
+ for (const [folder, heading, scopes] of buckets) {
232
+ const depMap = crossScopeDepMap(scopes);
233
+ const ordered = topoSortScopes(scopes, depMap);
234
+ lines.push(`### ${heading}\n`);
235
+ if (folder === "proposed") {
236
+ for (const line of PROPOSED_DISCLAIMER_LINES)
237
+ lines.push(line, "");
238
+ }
239
+ for (const [stem, vbrief] of ordered)
240
+ lines.push(...renderScopeBlock(stem, vbrief));
241
+ }
242
+ return lines;
243
+ }
244
+ /** Legacy wrapper: stakeholder export (no proposed). */
245
+ export function aggregateScopeSection(vbriefDir) {
246
+ return buildScopeOutlookSection(vbriefDir, { includeProposed: false });
247
+ }
248
+ //# sourceMappingURL=scope-outlook.js.map
@@ -1,117 +1,8 @@
1
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { LIFECYCLE_BUCKETS, RENDERABLE_SPEC_STATUSES, SCOPE_SUMMARY_NARRATIVES, SPEC_RENDER_BANNER, SPECIFICATION_NARRATIVE_KEY_ORDER, } from "./constants.js";
3
+ import { RENDERABLE_SPEC_STATUSES, SPEC_RENDER_BANNER, SPECIFICATION_NARRATIVE_KEY_ORDER, } from "./constants.js";
4
+ import { aggregateScopeSection } from "./scope-outlook.js";
4
5
  import { validateSpec } from "./spec-validate.js";
5
- function readEdgeEndpoints(edge) {
6
- if (typeof edge !== "object" || edge === null || Array.isArray(edge))
7
- return ["", ""];
8
- const e = edge;
9
- const frm = String(e.from ?? e.source ?? "") || "";
10
- const to = String(e.to ?? e.target ?? "") || "";
11
- return [frm, to];
12
- }
13
- function loadScopeVbriefs(folder) {
14
- if (!existsSync(folder))
15
- return [];
16
- let entries;
17
- try {
18
- entries = readdirSync(folder)
19
- .filter((n) => n.endsWith(".vbrief.json"))
20
- .sort();
21
- }
22
- catch {
23
- return [];
24
- }
25
- const out = [];
26
- for (const name of entries) {
27
- const path = join(folder, name);
28
- try {
29
- const data = JSON.parse(readFileSync(path, "utf8"));
30
- let stem = name;
31
- if (stem.endsWith(".vbrief.json"))
32
- stem = stem.slice(0, -".vbrief.json".length);
33
- out.push([stem, data]);
34
- }
35
- catch {
36
- /* skip malformed */
37
- }
38
- }
39
- return out;
40
- }
41
- function scopeId(stem, vbrief) {
42
- const plan = vbrief.plan;
43
- if (typeof plan === "object" && plan !== null && !Array.isArray(plan)) {
44
- const planId = plan.id;
45
- if (typeof planId === "string" && planId)
46
- return planId;
47
- }
48
- return stem;
49
- }
50
- function crossScopeDepMap(scopes) {
51
- const scopeIds = new Set(scopes.map(([stem, vb]) => scopeId(stem, vb)));
52
- const depMap = {};
53
- for (const [, vbrief] of scopes) {
54
- const plan = vbrief.plan;
55
- if (typeof plan !== "object" || plan === null || Array.isArray(plan))
56
- continue;
57
- const edges = plan.edges;
58
- if (!Array.isArray(edges))
59
- continue;
60
- for (const edge of edges) {
61
- const [frm, to] = readEdgeEndpoints(edge);
62
- if (frm && to && scopeIds.has(frm) && scopeIds.has(to)) {
63
- if (!depMap[to])
64
- depMap[to] = [];
65
- depMap[to].push(frm);
66
- }
67
- }
68
- }
69
- return depMap;
70
- }
71
- function topoSortScopes(scopes, depMap) {
72
- if (scopes.length === 0)
73
- return [];
74
- const idByIndex = scopes.map(([stem, vb]) => scopeId(stem, vb));
75
- const idToIndex = new Map(idByIndex.map((sid, i) => [sid, i]));
76
- const depths = {};
77
- const depth = (sid, visited = null) => {
78
- if (sid in depths)
79
- return depths[sid] ?? 0;
80
- const vis = visited ?? new Set();
81
- if (vis.has(sid))
82
- return 0;
83
- vis.add(sid);
84
- const deps = (depMap[sid] ?? []).filter((d) => idToIndex.has(d));
85
- if (deps.length === 0) {
86
- depths[sid] = 0;
87
- return 0;
88
- }
89
- const result = Math.max(...deps.map((d) => depth(d, vis))) + 1;
90
- depths[sid] = result;
91
- return result;
92
- };
93
- for (const sid of idByIndex)
94
- depth(sid);
95
- const orderedIndices = [...idByIndex.keys()].sort((a, b) => (depths[idByIndex[a] ?? ""] ?? 0) - (depths[idByIndex[b] ?? ""] ?? 0) || a - b);
96
- return orderedIndices.map((i) => scopes[i]);
97
- }
98
- function scopeSummaryNarrative(plan) {
99
- const narratives = plan.narratives;
100
- if (typeof narratives !== "object" || narratives === null || Array.isArray(narratives)) {
101
- return "";
102
- }
103
- const narr = narratives;
104
- for (const key of SCOPE_SUMMARY_NARRATIVES) {
105
- const val = narr[key];
106
- if (typeof val === "string" && val.trim())
107
- return val.trim();
108
- }
109
- for (const val of Object.values(narr)) {
110
- if (typeof val === "string" && val.trim())
111
- return val.trim();
112
- }
113
- return "";
114
- }
115
6
  function splitAcceptance(value) {
116
7
  if (Array.isArray(value)) {
117
8
  return value.map((item) => String(item).trim()).filter((s) => s.length > 0);
@@ -131,86 +22,6 @@ function splitAcceptance(value) {
131
22
  }
132
23
  return parts;
133
24
  }
134
- function itemAcceptance(item) {
135
- const narrative = item.narrative;
136
- if (typeof narrative !== "object" || narrative === null || Array.isArray(narrative))
137
- return [];
138
- return splitAcceptance(narrative.Acceptance);
139
- }
140
- function renderScopeBlock(stem, vbrief) {
141
- const plan = vbrief.plan;
142
- if (typeof plan !== "object" || plan === null || Array.isArray(plan))
143
- return [];
144
- const planObj = plan;
145
- const title = String(planObj.title ?? stem);
146
- const status = String(planObj.status ?? "");
147
- let heading = `### ${stem}: ${title}`;
148
- if (status)
149
- heading += ` \`[${status}]\``;
150
- const lines = [`${heading}\n`];
151
- const summary = scopeSummaryNarrative(planObj);
152
- if (summary)
153
- lines.push(`${summary}\n`);
154
- const narratives = planObj.narratives;
155
- if (typeof narratives === "object" && narratives !== null && !Array.isArray(narratives)) {
156
- const scopeAcceptance = splitAcceptance(narratives.Acceptance);
157
- if (scopeAcceptance.length > 0) {
158
- lines.push("**Scope Acceptance**:\n");
159
- for (const criterion of scopeAcceptance)
160
- lines.push(`- ${criterion}`);
161
- lines.push("");
162
- }
163
- }
164
- const items = planObj.items;
165
- if (Array.isArray(items) && items.length > 0) {
166
- lines.push("**Acceptance**:\n");
167
- for (const item of items) {
168
- if (typeof item !== "object" || item === null || Array.isArray(item))
169
- continue;
170
- const itemObj = item;
171
- const itemTitle = String(itemObj.title ?? "Untitled");
172
- const itemStatus = String(itemObj.status ?? "");
173
- let bullet = `- ${itemTitle}`;
174
- if (itemStatus)
175
- bullet += ` \`[${itemStatus}]\``;
176
- lines.push(bullet);
177
- for (const criterion of itemAcceptance(itemObj)) {
178
- if (criterion !== itemTitle)
179
- lines.push(` - Acceptance: ${criterion}`);
180
- }
181
- }
182
- lines.push("");
183
- }
184
- return lines;
185
- }
186
- function aggregateScopeSection(vbriefDir) {
187
- const buckets = [];
188
- for (const [folderName, heading] of LIFECYCLE_BUCKETS) {
189
- let scopes = loadScopeVbriefs(join(vbriefDir, folderName));
190
- if (folderName === "completed") {
191
- scopes = scopes.filter(([, vb]) => {
192
- const plan = vb.plan;
193
- return (typeof plan === "object" &&
194
- plan !== null &&
195
- !Array.isArray(plan) &&
196
- plan.status === "completed");
197
- });
198
- }
199
- if (scopes.length > 0)
200
- buckets.push([folderName, heading, scopes]);
201
- }
202
- if (buckets.length === 0)
203
- return [];
204
- const lines = ["## Implementation Plan\n"];
205
- for (const [, heading, scopes] of buckets) {
206
- const depMap = crossScopeDepMap(scopes);
207
- const ordered = topoSortScopes(scopes, depMap);
208
- lines.push(`### ${heading}\n`);
209
- for (const [stem, vbrief] of ordered)
210
- lines.push(...renderScopeBlock(stem, vbrief));
211
- }
212
- return lines;
213
- }
214
25
  /** Render specification JSON to markdown (mirrors ``scripts/spec_render.render_spec``). */
215
26
  export function renderSpec(specPath, outPath, options = {}) {
216
27
  const includeScopes = options.includeScopes ?? true;
@@ -0,0 +1,12 @@
1
+ /** Generated-spec banner tokens (lockstep with Python ``_precutover.py``). */
2
+ export declare const GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->";
3
+ export declare const GENERATED_SPEC_SOURCE_SPEC = "<!-- Source of truth: vbrief/specification.vbrief.json -->";
4
+ export declare const GENERATED_SPEC_SOURCE_PD = "<!-- Source of truth: vbrief/PROJECT-DEFINITION.vbrief.json -->";
5
+ export declare const EXPORT_SPEC_PD_BANNER: string;
6
+ /** Narratives excluded from stakeholder SPEC/PRD exports (#2013 Wave 0 §5). */
7
+ export declare const STAKEHOLDER_EXCLUDED_NARRATIVE_KEYS: Set<string>;
8
+ /** Product narrative keys used for migration-fidelity checks (#2005). */
9
+ export declare const PRODUCT_NARRATIVE_KEYS: readonly ["ProblemStatement", "Goals", "UserStories", "Requirements", "SuccessMetrics", "FunctionalRequirements", "NonFunctionalRequirements", "AcceptanceCriteria", "Architecture", "EdgeCases"];
10
+ export declare const PROPOSED_DISCLAIMER_LINES: readonly ["_Scopes in this section are ideas, not approved backlog. Promotion to pending is required before work starts._", "_Nothing here is in-flight or committed delivery scope._"];
11
+ export declare const PROPOSED_STATUS_FILTER: Set<string>;
12
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1,34 @@
1
+ /** Generated-spec banner tokens (lockstep with Python ``_precutover.py``). */
2
+ export const GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->";
3
+ export const GENERATED_SPEC_SOURCE_SPEC = "<!-- Source of truth: vbrief/specification.vbrief.json -->";
4
+ export const GENERATED_SPEC_SOURCE_PD = "<!-- Source of truth: vbrief/PROJECT-DEFINITION.vbrief.json -->";
5
+ export const EXPORT_SPEC_PD_BANNER = "<!-- AUTO-GENERATED by task project:export-spec -- DO NOT EDIT MANUALLY -->\n" +
6
+ `${GENERATED_SPEC_PURPOSE}\n` +
7
+ `${GENERATED_SPEC_SOURCE_PD}\n` +
8
+ "<!-- Regenerate with: task project:export-spec -->\n";
9
+ /** Narratives excluded from stakeholder SPEC/PRD exports (#2013 Wave 0 §5). */
10
+ export const STAKEHOLDER_EXCLUDED_NARRATIVE_KEYS = new Set([
11
+ "projectconfig",
12
+ "tech stack",
13
+ "techstack",
14
+ "configuration",
15
+ ]);
16
+ /** Product narrative keys used for migration-fidelity checks (#2005). */
17
+ export const PRODUCT_NARRATIVE_KEYS = [
18
+ "ProblemStatement",
19
+ "Goals",
20
+ "UserStories",
21
+ "Requirements",
22
+ "SuccessMetrics",
23
+ "FunctionalRequirements",
24
+ "NonFunctionalRequirements",
25
+ "AcceptanceCriteria",
26
+ "Architecture",
27
+ "EdgeCases",
28
+ ];
29
+ export const PROPOSED_DISCLAIMER_LINES = [
30
+ "_Scopes in this section are ideas, not approved backlog. Promotion to pending is required before work starts._",
31
+ "_Nothing here is in-flight or committed delivery scope._",
32
+ ];
33
+ export const PROPOSED_STATUS_FILTER = new Set(["draft", "proposed"]);
34
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1,5 @@
1
+ export * from "./constants.js";
2
+ export * from "./migration-fidelity.js";
3
+ export * from "./narratives.js";
4
+ export * from "./resolver.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export * from "./constants.js";
2
+ export * from "./migration-fidelity.js";
3
+ export * from "./narratives.js";
4
+ export * from "./resolver.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * #2005 migration-fidelity: when specification.vbrief.json is absent but a
3
+ * premigrate snapshot exists, require product narratives to land in PD or scopes.
4
+ */
5
+ export declare function checkSpecMigrationFidelity(projectRoot: string): string[];
6
+ //# sourceMappingURL=migration-fidelity.d.ts.map
@@ -0,0 +1,96 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PRODUCT_NARRATIVE_KEYS } from "./constants.js";
4
+ const LIFECYCLE_SCAN = ["proposed", "pending", "active", "completed", "cancelled"];
5
+ function loadJson(path) {
6
+ try {
7
+ return JSON.parse(readFileSync(path, "utf8"));
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ function planNarrativeKeys(doc) {
14
+ const keys = new Set();
15
+ const plan = doc.plan;
16
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
17
+ return keys;
18
+ const narratives = plan.narratives;
19
+ if (typeof narratives !== "object" || narratives === null || Array.isArray(narratives))
20
+ return keys;
21
+ for (const [key, val] of Object.entries(narratives)) {
22
+ if (typeof val === "string" && val.trim())
23
+ keys.add(key.toLowerCase());
24
+ }
25
+ return keys;
26
+ }
27
+ function collectCanonicalNarrativeKeys(vbriefDir) {
28
+ const keys = new Set();
29
+ const pdPath = join(vbriefDir, "PROJECT-DEFINITION.vbrief.json");
30
+ const pd = loadJson(pdPath);
31
+ if (pd) {
32
+ for (const k of planNarrativeKeys(pd))
33
+ keys.add(k);
34
+ }
35
+ for (const folder of LIFECYCLE_SCAN) {
36
+ const dir = join(vbriefDir, folder);
37
+ if (!existsSync(dir))
38
+ continue;
39
+ let names;
40
+ try {
41
+ names = readdirSync(dir).filter((n) => n.endsWith(".vbrief.json"));
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ for (const name of names) {
47
+ const doc = loadJson(join(dir, name));
48
+ if (doc) {
49
+ for (const k of planNarrativeKeys(doc))
50
+ keys.add(k);
51
+ }
52
+ }
53
+ }
54
+ return keys;
55
+ }
56
+ function premigrateNarrativeKeys(vbriefDir) {
57
+ const keys = new Set();
58
+ const premigrate = join(vbriefDir, "specification.premigrate.vbrief.json");
59
+ const doc = loadJson(premigrate);
60
+ if (!doc)
61
+ return keys;
62
+ for (const k of planNarrativeKeys(doc))
63
+ keys.add(k);
64
+ return keys;
65
+ }
66
+ function isProductKey(keyLower) {
67
+ if (keyLower === "overview")
68
+ return false;
69
+ return PRODUCT_NARRATIVE_KEYS.some((p) => p.toLowerCase() === keyLower);
70
+ }
71
+ /**
72
+ * #2005 migration-fidelity: when specification.vbrief.json is absent but a
73
+ * premigrate snapshot exists, require product narratives to land in PD or scopes.
74
+ */
75
+ export function checkSpecMigrationFidelity(projectRoot) {
76
+ const vbriefDir = join(projectRoot, "vbrief");
77
+ const specPath = join(vbriefDir, "specification.vbrief.json");
78
+ if (existsSync(specPath))
79
+ return [];
80
+ const premigratePath = join(vbriefDir, "specification.premigrate.vbrief.json");
81
+ if (!existsSync(premigratePath))
82
+ return [];
83
+ const premigrateKeys = premigrateNarrativeKeys(vbriefDir);
84
+ const productPremigrate = [...premigrateKeys].filter(isProductKey);
85
+ if (productPremigrate.length === 0)
86
+ return [];
87
+ const canonicalKeys = collectCanonicalNarrativeKeys(vbriefDir);
88
+ const missing = productPremigrate.filter((k) => !canonicalKeys.has(k));
89
+ if (missing.length === 0)
90
+ return [];
91
+ return [
92
+ `vbrief/specification.vbrief.json is absent but vbrief/specification.premigrate.vbrief.json retains product narratives not found in PROJECT-DEFINITION or lifecycle scope vBRIEFs: ${missing.join(", ")}. ` +
93
+ "Do not delete the legacy spec without migrating content. Run `task migrate:vbrief` or manually merge narratives into PROJECT-DEFINITION / scope vBRIEFs before removing the premigrate snapshot. See #2005.",
94
+ ];
95
+ }
96
+ //# sourceMappingURL=migration-fidelity.js.map
@@ -0,0 +1,6 @@
1
+ import type { ResolvedSpecAuthority } from "./resolver.js";
2
+ /** Merge product narratives per Wave 0 locked precedence. */
3
+ export declare function resolveExportNarratives(authority: ResolvedSpecAuthority): Record<string, string>;
4
+ export declare function renderNarrativeSections(narratives: Record<string, string>): string[];
5
+ export declare function greenfieldOverviewNonEmpty(authority: ResolvedSpecAuthority): boolean;
6
+ //# sourceMappingURL=narratives.d.ts.map