@deftai/directive-core 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ export type ExportAudience = "stakeholder" | "internal";
2
+ export interface ExportSpecOptions {
3
+ readonly projectRoot?: string;
4
+ readonly outPath?: string;
5
+ readonly audience?: ExportAudience;
6
+ readonly includeScopes?: boolean;
7
+ readonly proposedLimit?: number;
8
+ }
9
+ export type ExportSpecResult = readonly [boolean, string];
10
+ /** Unified spec export (#2013 / #1502). */
11
+ export declare function exportSpec(options?: ExportSpecOptions): ExportSpecResult;
12
+ export declare function parseExportSpecArgv(argv: readonly string[]): {
13
+ options: ExportSpecOptions;
14
+ errors: string[];
15
+ };
16
+ export declare function exportSpecMain(argv: readonly string[]): number;
17
+ //# sourceMappingURL=export-spec.d.ts.map
@@ -0,0 +1,104 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { greenfieldOverviewNonEmpty, renderNarrativeSections, resolveExportNarratives, } from "../spec-authority/narratives.js";
4
+ import { resolveSpecAuthority } from "../spec-authority/resolver.js";
5
+ import { buildScopeOutlookSection } from "./scope-outlook.js";
6
+ import { validateSpec } from "./spec-validate.js";
7
+ function loadPlanTitle(path, fallback) {
8
+ try {
9
+ const doc = JSON.parse(readFileSync(path, "utf8"));
10
+ const plan = doc.plan;
11
+ if (typeof plan === "object" && plan !== null && !Array.isArray(plan)) {
12
+ return String(plan.title ?? fallback);
13
+ }
14
+ }
15
+ catch {
16
+ /* ignore */
17
+ }
18
+ return fallback;
19
+ }
20
+ /** Unified spec export (#2013 / #1502). */
21
+ export function exportSpec(options = {}) {
22
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
23
+ const outPath = options.outPath ?? join(projectRoot, "SPECIFICATION.md");
24
+ const audience = options.audience ?? "stakeholder";
25
+ const includeScopes = options.includeScopes ?? true;
26
+ const includeProposed = audience === "internal";
27
+ const authority = resolveSpecAuthority(projectRoot);
28
+ if (!authority) {
29
+ return [false, "✗ Missing vbrief/PROJECT-DEFINITION.vbrief.json — cannot export spec."];
30
+ }
31
+ if (authority.kind === "full-spec" && authority.specPath) {
32
+ const [ok, msg] = validateSpec(authority.specPath);
33
+ if (!ok)
34
+ return [false, msg];
35
+ }
36
+ else if (!greenfieldOverviewNonEmpty(authority)) {
37
+ return [
38
+ false,
39
+ "⚠ PROJECT-DEFINITION.vbrief.json Overview narrative is empty (D3). Populate Overview before export.",
40
+ ];
41
+ }
42
+ const narratives = resolveExportNarratives(authority);
43
+ const title = authority.kind === "full-spec" && authority.specPath
44
+ ? loadPlanTitle(authority.specPath, "Specification")
45
+ : loadPlanTitle(authority.projectDefPath, "Specification");
46
+ const lines = [
47
+ authority.banner,
48
+ `# ${title}\n`,
49
+ ...renderNarrativeSections(narratives),
50
+ ];
51
+ if (includeScopes) {
52
+ const scopeLines = buildScopeOutlookSection(authority.vbriefDir, {
53
+ includeProposed,
54
+ proposedLimit: options.proposedLimit,
55
+ });
56
+ if (scopeLines.length > 0)
57
+ lines.push(...scopeLines);
58
+ }
59
+ writeFileSync(outPath, lines.join("\n"), "utf8");
60
+ return [true, `✓ Exported spec to ${outPath}`];
61
+ }
62
+ export function parseExportSpecArgv(argv) {
63
+ const options = {};
64
+ const errors = [];
65
+ const positional = [];
66
+ for (const arg of argv) {
67
+ if (arg === "--audience=stakeholder" || arg === "--audience=internal") {
68
+ options.audience = arg.split("=")[1];
69
+ continue;
70
+ }
71
+ if (arg.startsWith("--proposed-limit=")) {
72
+ const n = Number(arg.split("=", 2)[1]);
73
+ if (Number.isFinite(n) && n > 0)
74
+ options.proposedLimit = n;
75
+ continue;
76
+ }
77
+ if (arg === "--no-scopes") {
78
+ options.includeScopes = false;
79
+ continue;
80
+ }
81
+ if (arg.startsWith("--")) {
82
+ errors.push(`Unknown flag: ${arg}`);
83
+ continue;
84
+ }
85
+ positional.push(arg);
86
+ }
87
+ if (positional[0])
88
+ options.projectRoot = positional[0];
89
+ if (positional[1])
90
+ options.outPath = positional[1];
91
+ return { options: options, errors };
92
+ }
93
+ export function exportSpecMain(argv) {
94
+ const { options, errors } = parseExportSpecArgv(argv);
95
+ if (errors.length > 0) {
96
+ for (const e of errors)
97
+ console.error(e);
98
+ return 2;
99
+ }
100
+ const [ok, msg] = exportSpec(options);
101
+ console.log(msg);
102
+ return ok ? 0 : 1;
103
+ }
104
+ //# sourceMappingURL=export-spec.js.map
@@ -1,4 +1,5 @@
1
1
  export * from "./constants.js";
2
+ export { type ExportAudience, type ExportSpecOptions, exportSpec, exportSpecMain, parseExportSpecArgv, } from "./export-spec.js";
2
3
  export * as frameworkCommands from "./framework-commands.js";
3
4
  export { availableCommands, cmdCoreValidate, formatFrameworkCommand, hasCommand, main as frameworkCommandsMain, normalizeTaskSeparator, runFrameworkCommand, } from "./framework-commands.js";
4
5
  export * as prdRender from "./prd-render.js";
@@ -7,6 +8,7 @@ export * as projectRender from "./project-render.js";
7
8
  export { acknowledgeProjectDefinitionStaleness, buildStalenessAcknowledgement, computeStalenessFlags, flagStaleNarratives, main as projectRenderMain, parseStalenessReview, renderProjectDefinition, scanLifecycleFolders, unacknowledgedCompletedItems, } from "./project-render.js";
8
9
  export * as roadmapRender from "./roadmap-render.js";
9
10
  export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, renderRoadmapToBuffer, } from "./roadmap-render.js";
11
+ export { aggregateScopeSection, buildScopeOutlookSection } from "./scope-outlook.js";
10
12
  export * as specRender from "./spec-render.js";
11
13
  export { main as specRenderMain, parseIncludeScopesFlag, renderSpec } from "./spec-render.js";
12
14
  export * as specValidate from "./spec-validate.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./constants.js";
2
+ export { exportSpec, exportSpecMain, parseExportSpecArgv, } from "./export-spec.js";
2
3
  export * as frameworkCommands from "./framework-commands.js";
3
4
  export { availableCommands, cmdCoreValidate, formatFrameworkCommand, hasCommand, main as frameworkCommandsMain, normalizeTaskSeparator, runFrameworkCommand, } from "./framework-commands.js";
4
5
  export * as prdRender from "./prd-render.js";
@@ -7,6 +8,7 @@ export * as projectRender from "./project-render.js";
7
8
  export { acknowledgeProjectDefinitionStaleness, buildStalenessAcknowledgement, computeStalenessFlags, flagStaleNarratives, main as projectRenderMain, parseStalenessReview, renderProjectDefinition, scanLifecycleFolders, unacknowledgedCompletedItems, } from "./project-render.js";
8
9
  export * as roadmapRender from "./roadmap-render.js";
9
10
  export { checkDrift, generateRoadmapContent, main as roadmapRenderMain, renderRoadmap, renderRoadmapToBuffer, } from "./roadmap-render.js";
11
+ export { aggregateScopeSection, buildScopeOutlookSection } from "./scope-outlook.js";
10
12
  export * as specRender from "./spec-render.js";
11
13
  export { main as specRenderMain, parseIncludeScopesFlag, renderSpec } from "./spec-render.js";
12
14
  export * as specValidate from "./spec-validate.js";
@@ -0,0 +1,9 @@
1
+ export interface ScopeOutlookOptions {
2
+ readonly includeProposed?: boolean;
3
+ readonly proposedLimit?: number;
4
+ }
5
+ /** Build Scope outlook section per #2013 Wave 0 §4. */
6
+ export declare function buildScopeOutlookSection(vbriefDir: string, options?: ScopeOutlookOptions): string[];
7
+ /** Legacy wrapper: stakeholder export (no proposed). */
8
+ export declare function aggregateScopeSection(vbriefDir: string): string[];
9
+ //# sourceMappingURL=scope-outlook.d.ts.map
@@ -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
@@ -0,0 +1,84 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { SPECIFICATION_NARRATIVE_KEY_ORDER } from "../render/constants.js";
3
+ import { STAKEHOLDER_EXCLUDED_NARRATIVE_KEYS } from "./constants.js";
4
+ function loadJson(path) {
5
+ return JSON.parse(readFileSync(path, "utf8"));
6
+ }
7
+ function planNarratives(doc) {
8
+ const plan = doc.plan;
9
+ if (typeof plan !== "object" || plan === null || Array.isArray(plan))
10
+ return {};
11
+ const narratives = plan.narratives;
12
+ if (typeof narratives !== "object" || narratives === null || Array.isArray(narratives))
13
+ return {};
14
+ const out = {};
15
+ for (const [key, val] of Object.entries(narratives)) {
16
+ if (typeof val === "string")
17
+ out[key] = val;
18
+ }
19
+ return out;
20
+ }
21
+ function isExcludedNarrativeKey(key) {
22
+ return STAKEHOLDER_EXCLUDED_NARRATIVE_KEYS.has(key.toLowerCase().replace(/\s+/g, ""));
23
+ }
24
+ /** Merge product narratives per Wave 0 locked precedence. */
25
+ export function resolveExportNarratives(authority) {
26
+ const pd = loadJson(authority.projectDefPath);
27
+ const pdNarratives = planNarratives(pd);
28
+ if (authority.kind === "greenfield") {
29
+ const filtered = {};
30
+ for (const [key, val] of Object.entries(pdNarratives)) {
31
+ if (!isExcludedNarrativeKey(key) && val.trim())
32
+ filtered[key] = val;
33
+ }
34
+ return filtered;
35
+ }
36
+ const specPath = authority.specPath;
37
+ if (!specPath)
38
+ return pdNarratives;
39
+ const spec = loadJson(specPath);
40
+ const specNarratives = planNarratives(spec);
41
+ const merged = {};
42
+ for (const [key, val] of Object.entries(specNarratives)) {
43
+ if (!isExcludedNarrativeKey(key) && val.trim())
44
+ merged[key] = val;
45
+ }
46
+ // PD identity additive when missing from spec.
47
+ const identityHints = ["overview", "architecture", "risksandunknowns", "risks", "unknowns"];
48
+ for (const [key, val] of Object.entries(pdNarratives)) {
49
+ if (!val.trim() || isExcludedNarrativeKey(key))
50
+ continue;
51
+ const lower = key.toLowerCase().replace(/\s+/g, "");
52
+ const isIdentity = identityHints.some((h) => lower.includes(h)) || lower === "overview";
53
+ if (!isIdentity)
54
+ continue;
55
+ const specHasKey = Object.keys(merged).some((k) => k.toLowerCase() === lower);
56
+ if (!specHasKey)
57
+ merged[key] = val;
58
+ }
59
+ return merged;
60
+ }
61
+ export function renderNarrativeSections(narratives) {
62
+ const lines = [];
63
+ const rendered = new Set();
64
+ for (const key of SPECIFICATION_NARRATIVE_KEY_ORDER) {
65
+ const val = narratives[key];
66
+ if (val?.trim()) {
67
+ lines.push(`## ${key}\n`, `${val}\n`);
68
+ rendered.add(key);
69
+ }
70
+ }
71
+ for (const key of Object.keys(narratives).sort()) {
72
+ if (rendered.has(key) || !narratives[key]?.trim())
73
+ continue;
74
+ lines.push(`## ${key}\n`, `${narratives[key]}\n`);
75
+ }
76
+ return lines;
77
+ }
78
+ export function greenfieldOverviewNonEmpty(authority) {
79
+ const pd = loadJson(authority.projectDefPath);
80
+ const narratives = planNarratives(pd);
81
+ const overview = Object.entries(narratives).find(([k]) => k.toLowerCase() === "overview");
82
+ return overview !== undefined && overview[1].trim().length > 0;
83
+ }
84
+ //# sourceMappingURL=narratives.js.map
@@ -0,0 +1,15 @@
1
+ export type SpecAuthorityKind = "full-spec" | "greenfield";
2
+ export interface ResolvedSpecAuthority {
3
+ readonly kind: SpecAuthorityKind;
4
+ readonly projectRoot: string;
5
+ readonly vbriefDir: string;
6
+ readonly projectDefPath: string;
7
+ readonly specPath: string | null;
8
+ readonly banner: string;
9
+ }
10
+ export declare function resolveSpecAuthority(projectRoot: string): ResolvedSpecAuthority | null;
11
+ export declare function readSpecMarkdown(projectRoot: string): string;
12
+ export declare function isFullSpecState(projectRoot: string): boolean;
13
+ export declare function isGreenfieldSpecExport(projectRoot: string): boolean;
14
+ export declare function isCurrentGeneratedSpecification(projectRoot: string): boolean;
15
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { SPEC_RENDER_BANNER } from "../render/constants.js";
4
+ import { EXPORT_SPEC_PD_BANNER, GENERATED_SPEC_PURPOSE, GENERATED_SPEC_SOURCE_PD, GENERATED_SPEC_SOURCE_SPEC, } from "./constants.js";
5
+ const SPEC_REL = join("vbrief", "specification.vbrief.json");
6
+ const PD_REL = join("vbrief", "PROJECT-DEFINITION.vbrief.json");
7
+ export function resolveSpecAuthority(projectRoot) {
8
+ const root = projectRoot;
9
+ const vbriefDir = join(root, "vbrief");
10
+ const projectDefPath = join(root, PD_REL);
11
+ if (!existsSync(projectDefPath))
12
+ return null;
13
+ const specPath = join(root, SPEC_REL);
14
+ const hasSpec = existsSync(specPath);
15
+ const kind = hasSpec ? "full-spec" : "greenfield";
16
+ const banner = kind === "full-spec" ? SPEC_RENDER_BANNER : EXPORT_SPEC_PD_BANNER;
17
+ return {
18
+ kind,
19
+ projectRoot: root,
20
+ vbriefDir,
21
+ projectDefPath,
22
+ specPath: hasSpec ? specPath : null,
23
+ banner,
24
+ };
25
+ }
26
+ export function readSpecMarkdown(projectRoot) {
27
+ const path = join(projectRoot, "SPECIFICATION.md");
28
+ try {
29
+ return readFileSync(path, "utf8");
30
+ }
31
+ catch {
32
+ return "";
33
+ }
34
+ }
35
+ export function isFullSpecState(projectRoot) {
36
+ const authority = resolveSpecAuthority(projectRoot);
37
+ if (authority?.kind !== "full-spec")
38
+ return false;
39
+ const specMd = readSpecMarkdown(projectRoot);
40
+ return specMd.includes(GENERATED_SPEC_PURPOSE) && specMd.includes(GENERATED_SPEC_SOURCE_SPEC);
41
+ }
42
+ export function isGreenfieldSpecExport(projectRoot) {
43
+ const authority = resolveSpecAuthority(projectRoot);
44
+ if (authority?.kind !== "greenfield")
45
+ return false;
46
+ const specMd = readSpecMarkdown(projectRoot);
47
+ return specMd.includes(GENERATED_SPEC_PURPOSE) && specMd.includes(GENERATED_SPEC_SOURCE_PD);
48
+ }
49
+ export function isCurrentGeneratedSpecification(projectRoot) {
50
+ return isFullSpecState(projectRoot) || isGreenfieldSpecExport(projectRoot);
51
+ }
52
+ //# sourceMappingURL=resolver.js.map
@@ -1,17 +1,9 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { checkSpecMigrationFidelity } from "../spec-authority/migration-fidelity.js";
4
+ import { isFullSpecState } from "../spec-authority/resolver.js";
3
5
  import { isDatePrefixedVbriefFilename } from "./filename.js";
4
- const GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->";
5
- const GENERATED_SPEC_SOURCE = "<!-- Source of truth: vbrief/specification.vbrief.json -->";
6
6
  const LIFECYCLE_DIRS = ["proposed", "pending", "active", "completed", "cancelled"];
7
- function readTextSafe(path) {
8
- try {
9
- return readFileSync(path, "utf8");
10
- }
11
- catch {
12
- return "";
13
- }
14
- }
15
7
  function isDirSafe(path) {
16
8
  try {
17
9
  return statSync(path).isDirectory();
@@ -30,20 +22,6 @@ function isDeftFrameworkRoot(projectRoot) {
30
22
  (isDirSafe(join(projectRoot, "content", "strategies")) ||
31
23
  isDirSafe(join(projectRoot, "strategies"))));
32
24
  }
33
- function hasCompleteLifecycle(vbriefDir) {
34
- return LIFECYCLE_DIRS.every((folder) => {
35
- const p = join(vbriefDir, folder);
36
- return existsSync(p) && statSync(p).isDirectory();
37
- });
38
- }
39
- function isPostCutoverFullSpecState(projectRoot) {
40
- const vbriefDir = join(projectRoot, "vbrief");
41
- const specMd = readTextSafe(join(projectRoot, "SPECIFICATION.md"));
42
- return (existsSync(join(vbriefDir, "PROJECT-DEFINITION.vbrief.json")) &&
43
- hasCompleteLifecycle(vbriefDir) &&
44
- specMd.includes(GENERATED_SPEC_PURPOSE) &&
45
- specMd.includes(GENERATED_SPEC_SOURCE));
46
- }
47
25
  /** Pure validator returning error strings (empty == pass). */
48
26
  export function validateStrategyOutput(projectRoot, strict = false) {
49
27
  const root = resolve(projectRoot);
@@ -63,7 +41,7 @@ export function validateStrategyOutput(projectRoot, strict = false) {
63
41
  "(see v0-20-contract.md and task project:render).");
64
42
  }
65
43
  const specLegacy = join(vbriefDir, "specification.vbrief.json");
66
- if (existsSync(specLegacy) && !isDeftFrameworkRoot(root) && !isPostCutoverFullSpecState(root)) {
44
+ if (existsSync(specLegacy) && !isDeftFrameworkRoot(root) && !isFullSpecState(root)) {
67
45
  errors.push("Legacy artifact vbrief/specification.vbrief.json present. " +
68
46
  "v0.20 strategies MUST NOT dual-write the old specification.vbrief.json " +
69
47
  "alongside scope vBRIEFs in the lifecycle folders. " +
@@ -85,6 +63,7 @@ export function validateStrategyOutput(projectRoot, strict = false) {
85
63
  }
86
64
  }
87
65
  }
66
+ errors.push(...checkSpecMigrationFidelity(root));
88
67
  return errors;
89
68
  }
90
69
  const FAILURE_HEADER = "❌ Strategy output shape validation FAILED (v0.20 contract gate)";
@@ -122,6 +122,11 @@ export function cmdVbriefValidate(argv) {
122
122
  if (argv[0] === "conformance") {
123
123
  return runConformance(argv.slice(1));
124
124
  }
125
+ if (argv.includes("--staged") ||
126
+ (argv.includes("--all") &&
127
+ !argv.some((a) => a === "--vbrief-dir" || a.startsWith("--vbrief-dir=")))) {
128
+ return runConformance(argv);
129
+ }
125
130
  return runValidate(argv);
126
131
  }
127
132
  //# sourceMappingURL=main.js.map
@@ -1,35 +1,21 @@
1
+ import { isFullSpecState, isGreenfieldSpecExport } from "../spec-authority/resolver.js";
1
2
  import { DEPRECATION_SENTINEL } from "../vbrief-build/constants.js";
2
3
  export { DEPRECATION_SENTINEL as DEPRECATED_REDIRECT_SENTINEL };
3
4
  export declare function missingLifecycleFolders(projectRoot: string): string[];
4
5
  /** Return true when markdown content is a migration redirect stub. */
5
6
  export declare function isDeprecationRedirect(content: string): boolean;
7
+ /** Full-spec generated export (specification.vbrief.json source line). */
6
8
  export declare function isGeneratedSpecificationExport(projectRoot: string, content: string): boolean;
7
- /** Return true for a fully current ``task spec:render`` root export. */
9
+ /** Return true for a fully current generated spec export (full-spec or greenfield). */
8
10
  export declare function isCurrentGeneratedSpecification(projectRoot: string, content: string): boolean;
9
11
  /** Return root artifact filenames that are legacy pre-v0.20 inputs (#793 / migrate preflight). */
10
12
  export declare function detectPreCutoverLegacy(projectRoot: string): string[];
11
13
  /** Structured result of a pre-cutover (pre-v0.20 document model) probe. */
12
14
  export interface PrecutoverDetection {
13
- /** True when any legacy pre-v0.20 artifact still needs migration. */
14
15
  preCutover: boolean;
15
- /** Human-readable reasons; empty when the project is on the current model. */
16
16
  reasons: string[];
17
17
  }
18
- /**
19
- * Detect whether ``projectRoot`` still carries pre-v0.20 (pre-cutover) document-model
20
- * artifacts that need migration. Mirrors the AGENTS.md "Pre-Cutover Check" rule and the
21
- * legacy ``scripts/_precutover.py`` helper: a project is pre-cutover when any of the
22
- * following hold:
23
- *
24
- * - ``SPECIFICATION.md`` exists and is neither a deprecation redirect nor a current
25
- * generated spec export;
26
- * - ``PROJECT.md`` exists and is not a deprecation redirect;
27
- * - ``vbrief/`` exists but is missing one or more lifecycle folders.
28
- */
29
18
  export declare function detectPreCutover(projectRoot: string): PrecutoverDetection;
30
- /**
31
- * Render the single-line pre-cutover status string surfaced by ``deft doctor``.
32
- * Flags the migration-needed state with reasons, or reports a clean current-model state.
33
- */
34
19
  export declare function renderPrecutoverLine(projectRoot: string): string;
20
+ export { isFullSpecState, isGreenfieldSpecExport };
35
21
  //# sourceMappingURL=precutover.d.ts.map
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { GENERATED_SPEC_PURPOSE, GENERATED_SPEC_SOURCE_SPEC } from "../spec-authority/constants.js";
4
+ import { isFullSpecState, isGreenfieldSpecExport } from "../spec-authority/resolver.js";
3
5
  import { DEPRECATION_SENTINEL } from "../vbrief-build/constants.js";
4
6
  export { DEPRECATION_SENTINEL as DEPRECATED_REDIRECT_SENTINEL };
5
7
  const DEPRECATION_REDIRECT_PURPOSE = "<!-- Purpose: deprecation redirect -->";
6
- const GENERATED_SPEC_PURPOSE = "<!-- Purpose: rendered specification -->";
7
- const GENERATED_SPEC_SOURCE = "<!-- Source of truth: vbrief/specification.vbrief.json -->";
8
8
  const SPEC_SOURCE_RELPATH = join("vbrief", "specification.vbrief.json");
9
9
  const LIFECYCLE_FOLDERS = ["proposed", "pending", "active", "completed", "cancelled"];
10
10
  export function missingLifecycleFolders(projectRoot) {
@@ -18,14 +18,19 @@ function hasCompleteLifecycle(projectRoot) {
18
18
  export function isDeprecationRedirect(content) {
19
19
  return content.includes(DEPRECATION_SENTINEL) || content.includes(DEPRECATION_REDIRECT_PURPOSE);
20
20
  }
21
+ /** Full-spec generated export (specification.vbrief.json source line). */
21
22
  export function isGeneratedSpecificationExport(projectRoot, content) {
22
23
  return (content.includes(GENERATED_SPEC_PURPOSE) &&
23
- content.includes(GENERATED_SPEC_SOURCE) &&
24
+ content.includes(GENERATED_SPEC_SOURCE_SPEC) &&
24
25
  existsSync(join(projectRoot, SPEC_SOURCE_RELPATH)));
25
26
  }
26
- /** Return true for a fully current ``task spec:render`` root export. */
27
+ /** Return true for a fully current generated spec export (full-spec or greenfield). */
27
28
  export function isCurrentGeneratedSpecification(projectRoot, content) {
28
- return isGeneratedSpecificationExport(projectRoot, content) && hasCompleteLifecycle(projectRoot);
29
+ if (!hasCompleteLifecycle(projectRoot))
30
+ return false;
31
+ if (isGeneratedSpecificationExport(projectRoot, content))
32
+ return true;
33
+ return isGreenfieldSpecExport(projectRoot);
29
34
  }
30
35
  function isFile(path) {
31
36
  try {
@@ -46,8 +51,11 @@ function safeReadText(path) {
46
51
  function rootMarkdownIsLegacy(projectRoot, filename, content) {
47
52
  if (isDeprecationRedirect(content))
48
53
  return false;
49
- if (filename === "SPECIFICATION.md" && isGeneratedSpecificationExport(projectRoot, content)) {
50
- return false;
54
+ if (filename === "SPECIFICATION.md") {
55
+ if (isGeneratedSpecificationExport(projectRoot, content))
56
+ return false;
57
+ if (isGreenfieldSpecExport(projectRoot))
58
+ return false;
51
59
  }
52
60
  return filename === "SPECIFICATION.md" || filename === "PROJECT.md";
53
61
  }
@@ -65,17 +73,6 @@ export function detectPreCutoverLegacy(projectRoot) {
65
73
  }
66
74
  return legacy;
67
75
  }
68
- /**
69
- * Detect whether ``projectRoot`` still carries pre-v0.20 (pre-cutover) document-model
70
- * artifacts that need migration. Mirrors the AGENTS.md "Pre-Cutover Check" rule and the
71
- * legacy ``scripts/_precutover.py`` helper: a project is pre-cutover when any of the
72
- * following hold:
73
- *
74
- * - ``SPECIFICATION.md`` exists and is neither a deprecation redirect nor a current
75
- * generated spec export;
76
- * - ``PROJECT.md`` exists and is not a deprecation redirect;
77
- * - ``vbrief/`` exists but is missing one or more lifecycle folders.
78
- */
79
76
  export function detectPreCutover(projectRoot) {
80
77
  const reasons = [];
81
78
  const specPath = join(projectRoot, "SPECIFICATION.md");
@@ -101,17 +98,14 @@ export function detectPreCutover(projectRoot) {
101
98
  }
102
99
  return { preCutover: reasons.length > 0, reasons };
103
100
  }
104
- /**
105
- * Render the single-line pre-cutover status string surfaced by ``deft doctor``.
106
- * Flags the migration-needed state with reasons, or reports a clean current-model state.
107
- */
108
101
  export function renderPrecutoverLine(projectRoot) {
109
102
  const { preCutover, reasons } = detectPreCutover(projectRoot);
110
103
  if (!preCutover) {
111
104
  return "Pre-cutover: none -- project is on the current vBRIEF document model.";
112
105
  }
113
- // Collapse any embedded newlines so the status line stays a single line (CWE-116).
114
106
  const summary = reasons.join("; ").replace(/\r?\n/g, " ");
115
107
  return `Pre-cutover: migration needed -- ${summary}. Run \`deft migrate:vbrief\` to migrate.`;
116
108
  }
109
+ // Re-export for classify callers (#2013).
110
+ export { isFullSpecState, isGreenfieldSpecExport };
117
111
  //# sourceMappingURL=precutover.js.map
@@ -5,9 +5,9 @@ export interface EvaluateResult {
5
5
  readonly stream: OutputStream;
6
6
  }
7
7
  export declare const REQUIRED_HOOKS: readonly ["pre-commit", "pre-push"];
8
- export declare const SCRIPTS_PROBE = "preflight_branch.py";
9
- export declare const GATE_SCRIPTS: readonly ["preflight_branch.py", "verify_encoding.py", "preflight_gh.py"];
10
- export declare const SCRIPTS_DIR_CANDIDATES: readonly ["scripts", ".deft/core/scripts", "deft/scripts"];
8
+ /** Substrings each hook must contain when it dispatches through the deft CLI (#2049). */
9
+ export declare const PRE_COMMIT_DEFT_COMMANDS: readonly ["verify:branch", "verify:encoding"];
10
+ export declare const PRE_PUSH_DEFT_COMMANDS: readonly ["preflight-gh"];
11
11
  export type GitConfigReader = (projectRoot: string) => {
12
12
  hooksPath: string | null;
13
13
  error: string | null;
@@ -1,14 +1,18 @@
1
1
  import * as childProcess from "node:child_process";
2
- import { accessSync, constants, statSync } from "node:fs";
2
+ import { accessSync, constants, readFileSync, statSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  export const REQUIRED_HOOKS = ["pre-commit", "pre-push"];
5
- export const SCRIPTS_PROBE = "preflight_branch.py";
6
- export const GATE_SCRIPTS = [
7
- "preflight_branch.py",
8
- "verify_encoding.py",
9
- "preflight_gh.py",
5
+ /** Substrings each hook must contain when it dispatches through the deft CLI (#2049). */
6
+ export const PRE_COMMIT_DEFT_COMMANDS = ["verify:branch", "verify:encoding"];
7
+ export const PRE_PUSH_DEFT_COMMANDS = ["preflight-gh"];
8
+ /** Patterns that indicate legacy Python-dispatched hooks (pre-#2049). */
9
+ const LEGACY_HOOK_PATTERNS = [
10
+ /\.py\b/i,
11
+ /\bpython\b/i,
12
+ /\bdeft_py\b/,
13
+ /\bSCRIPTS_DIR\b/,
14
+ /\bpreflight_branch\.py\b/,
10
15
  ];
11
- export const SCRIPTS_DIR_CANDIDATES = ["scripts", ".deft/core/scripts", "deft/scripts"];
12
16
  function defaultGitConfigReader(projectRoot) {
13
17
  try {
14
18
  const stdout = childProcess.execFileSync("git", ["-C", projectRoot, "config", "--get", "core.hooksPath"], {
@@ -45,15 +49,6 @@ function isDirectory(path) {
45
49
  return false;
46
50
  }
47
51
  }
48
- function resolveScriptsDir(projectRoot) {
49
- for (const rel of SCRIPTS_DIR_CANDIDATES) {
50
- const candidate = join(projectRoot, rel);
51
- if (isFile(join(candidate, SCRIPTS_PROBE))) {
52
- return candidate;
53
- }
54
- }
55
- return null;
56
- }
57
52
  function isPosix(platform) {
58
53
  return platform !== "win32";
59
54
  }
@@ -66,6 +61,36 @@ function hookExecutable(hookPath) {
66
61
  return false;
67
62
  }
68
63
  }
64
+ function readHookContent(hookPath) {
65
+ try {
66
+ return readFileSync(hookPath, "utf8");
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ function usesLegacyPythonDispatch(content) {
73
+ return LEGACY_HOOK_PATTERNS.some((pattern) => pattern.test(content));
74
+ }
75
+ function hookInvokesDeftCli(content, requiredCommands) {
76
+ if (!/\bdeft\b/.test(content))
77
+ return false;
78
+ if (usesLegacyPythonDispatch(content))
79
+ return false;
80
+ return requiredCommands.every((cmd) => content.includes(cmd));
81
+ }
82
+ function validateHookContent(hookName, content, requiredCommands) {
83
+ if (content === null) {
84
+ return `${hookName}: unreadable hook file`;
85
+ }
86
+ if (usesLegacyPythonDispatch(content)) {
87
+ return `${hookName}: still dispatches through Python scripts (expected deft CLI only, #2049)`;
88
+ }
89
+ if (!hookInvokesDeftCli(content, requiredCommands)) {
90
+ return `${hookName}: missing required deft CLI gate(s): ${requiredCommands.join(", ")}`;
91
+ }
92
+ return null;
93
+ }
69
94
  /** Pure evaluator mirroring scripts/verify_hooks_installed.py::evaluate. */
70
95
  export function evaluate(projectRoot, options = {}) {
71
96
  const root = resolve(projectRoot);
@@ -132,30 +157,37 @@ export function evaluate(projectRoot, options = {}) {
132
157
  };
133
158
  }
134
159
  }
135
- const scriptsDir = resolveScriptsDir(root);
136
- if (scriptsDir === null) {
160
+ const preCommitIssue = validateHookContent("pre-commit", readHookContent(join(hooksDir, "pre-commit")), PRE_COMMIT_DEFT_COMMANDS);
161
+ if (preCommitIssue) {
162
+ return {
163
+ code: 1,
164
+ message: `❌ deft hooks wired but NON-FUNCTIONAL: ${preCommitIssue} (#2049).\n` +
165
+ " Recovery: re-run the deft installer / `task setup` to refresh .githooks/.",
166
+ stream: "stderr",
167
+ };
168
+ }
169
+ const prePushContent = readHookContent(join(hooksDir, "pre-push"));
170
+ const prePushIssue = validateHookContent("pre-push", prePushContent, PRE_PUSH_DEFT_COMMANDS);
171
+ if (prePushIssue) {
137
172
  return {
138
173
  code: 1,
139
- message: "❌ deft hooks wired but NON-FUNCTIONAL: the gate scripts cannot be resolved.\n" +
140
- ` Looked for ${SCRIPTS_PROBE} under: ${SCRIPTS_DIR_CANDIDATES.join(", ")} (relative to ${root}).\n` +
141
- " Recovery: re-run the deft installer so the payload is present.",
174
+ message: `❌ deft hooks wired but NON-FUNCTIONAL: ${prePushIssue} (#2049).\n` +
175
+ " Recovery: re-run the deft installer / `task setup` to refresh .githooks/.",
142
176
  stream: "stderr",
143
177
  };
144
178
  }
145
- const missingScripts = GATE_SCRIPTS.filter((s) => !isFile(join(scriptsDir, s)));
146
- if (missingScripts.length > 0) {
179
+ if (prePushContent?.includes("verify:branch")) {
147
180
  return {
148
181
  code: 1,
149
- message: `❌ deft hooks wired but NON-FUNCTIONAL: ${scriptsDir} is missing ` +
150
- `gate script(s): ${missingScripts.join(", ")} (#1463 false-green).\n` +
151
- " Recovery: re-run the deft installer to restore the payload.",
182
+ message: "❌ deft hooks wired but NON-FUNCTIONAL: pre-push must not invoke verify:branch (#1814).\n" +
183
+ " Recovery: re-run the deft installer / `task setup` to refresh .githooks/.",
152
184
  stream: "stderr",
153
185
  };
154
186
  }
155
187
  return {
156
188
  code: 0,
157
189
  message: `✓ deft hooks installed and functional: core.hooksPath=${hooksPath}, ` +
158
- `hooks ${REQUIRED_HOOKS.join(", ")} present, gate scripts resolve under ${scriptsDir}.`,
190
+ `hooks ${REQUIRED_HOOKS.join(", ")} present and dispatch via deft CLI (#2049).`,
159
191
  stream: "stdout",
160
192
  };
161
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deftai/directive-core",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "TypeScript engine core for the Directive framework.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -221,8 +221,8 @@
221
221
  "provenance": true
222
222
  },
223
223
  "dependencies": {
224
- "@deftai/directive-content": "^0.60.0",
225
- "@deftai/directive-types": "^0.60.0"
224
+ "@deftai/directive-content": "^0.61.0",
225
+ "@deftai/directive-types": "^0.61.0"
226
226
  },
227
227
  "scripts": {
228
228
  "build": "tsc -b",