gswd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/gswd/architecture-drafter.md +70 -0
- package/agents/gswd/brainstorm-alternatives.md +60 -0
- package/agents/gswd/devils-advocate.md +57 -0
- package/agents/gswd/icp-persona.md +58 -0
- package/agents/gswd/integrations-checker.md +68 -0
- package/agents/gswd/journey-mapper.md +69 -0
- package/agents/gswd/market-researcher.md +54 -0
- package/agents/gswd/positioning.md +54 -0
- package/bin/gswd-tools.cjs +716 -0
- package/lib/audit.ts +959 -0
- package/lib/bootstrap.ts +617 -0
- package/lib/compile.ts +940 -0
- package/lib/config.ts +164 -0
- package/lib/imagine-agents.ts +154 -0
- package/lib/imagine-gate.ts +156 -0
- package/lib/imagine-input.ts +242 -0
- package/lib/imagine-synthesis.ts +402 -0
- package/lib/imagine.ts +433 -0
- package/lib/parse.ts +196 -0
- package/lib/render.ts +200 -0
- package/lib/specify-agents.ts +332 -0
- package/lib/specify-journeys.ts +410 -0
- package/lib/specify-nfr.ts +208 -0
- package/lib/specify-roles.ts +122 -0
- package/lib/specify.ts +773 -0
- package/lib/state.ts +305 -0
- package/package.json +26 -0
- package/templates/gswd/ARCHITECTURE.template.md +17 -0
- package/templates/gswd/AUDIT.template.md +31 -0
- package/templates/gswd/COMPETITION.template.md +18 -0
- package/templates/gswd/DECISIONS.template.md +18 -0
- package/templates/gswd/GTM.template.md +18 -0
- package/templates/gswd/ICP.template.md +18 -0
- package/templates/gswd/IMAGINE.template.md +24 -0
- package/templates/gswd/INTEGRATIONS.template.md +7 -0
- package/templates/gswd/JOURNEYS.template.md +7 -0
- package/templates/gswd/NFR.template.md +7 -0
- package/templates/gswd/PROJECT.template.md +21 -0
- package/templates/gswd/REQUIREMENTS.template.md +31 -0
- package/templates/gswd/ROADMAP.template.md +21 -0
- package/templates/gswd/SPEC.template.md +19 -0
- package/templates/gswd/STATE.template.md +15 -0
package/lib/config.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Config Module — config.json merge under gswd key
|
|
3
|
+
*
|
|
4
|
+
* Config is stored in .planning/config.json under the `gswd` key.
|
|
5
|
+
* Merge operations NEVER touch keys outside `gswd`.
|
|
6
|
+
*
|
|
7
|
+
* Schema: GSWD_SPEC.md Section 5.2
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import { safeWriteJson } from './state.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface GswdAutoConfig {
|
|
16
|
+
policy: 'strict' | 'balanced' | 'aggressive';
|
|
17
|
+
allow_paid_integrations_under_budget: boolean;
|
|
18
|
+
default_auth_model: string;
|
|
19
|
+
default_data_store: string;
|
|
20
|
+
default_stack: string;
|
|
21
|
+
preapproved_integrations: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GswdConfig {
|
|
25
|
+
mode: string;
|
|
26
|
+
strict_gates: boolean;
|
|
27
|
+
max_parallel_agents: number;
|
|
28
|
+
external_research: boolean;
|
|
29
|
+
integration_budget_usd_month: number;
|
|
30
|
+
doc_verbosity: string;
|
|
31
|
+
phase_style: string;
|
|
32
|
+
auto: GswdAutoConfig;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default GSWD config matching GSWD_SPEC Section 5.2.
|
|
40
|
+
*/
|
|
41
|
+
export const GSWD_CONFIG_DEFAULTS: GswdConfig = {
|
|
42
|
+
mode: 'balanced',
|
|
43
|
+
strict_gates: true,
|
|
44
|
+
max_parallel_agents: 4,
|
|
45
|
+
external_research: true,
|
|
46
|
+
integration_budget_usd_month: 100,
|
|
47
|
+
doc_verbosity: 'normal',
|
|
48
|
+
phase_style: 'thin',
|
|
49
|
+
auto: {
|
|
50
|
+
policy: 'balanced',
|
|
51
|
+
allow_paid_integrations_under_budget: false,
|
|
52
|
+
default_auth_model: 'passwordless_email',
|
|
53
|
+
default_data_store: 'sqlite',
|
|
54
|
+
default_stack: 'node_typescript',
|
|
55
|
+
preapproved_integrations: [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ─── Config CRUD ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read config.json. Returns empty object if missing or invalid.
|
|
63
|
+
*/
|
|
64
|
+
export function readConfig(configPath: string): Record<string, unknown> {
|
|
65
|
+
try {
|
|
66
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
67
|
+
const parsed = JSON.parse(content);
|
|
68
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
return parsed as Record<string, unknown>;
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Deep merge helper: merge source into target, source values take precedence.
|
|
79
|
+
* Only merges plain objects recursively; arrays and primitives are replaced.
|
|
80
|
+
*/
|
|
81
|
+
function deepMerge(
|
|
82
|
+
target: Record<string, unknown>,
|
|
83
|
+
source: Record<string, unknown>
|
|
84
|
+
): Record<string, unknown> {
|
|
85
|
+
const result: Record<string, unknown> = { ...target };
|
|
86
|
+
|
|
87
|
+
for (const key of Object.keys(source)) {
|
|
88
|
+
const sourceVal = source[key];
|
|
89
|
+
const targetVal = result[key];
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
typeof sourceVal === 'object' &&
|
|
93
|
+
sourceVal !== null &&
|
|
94
|
+
!Array.isArray(sourceVal) &&
|
|
95
|
+
typeof targetVal === 'object' &&
|
|
96
|
+
targetVal !== null &&
|
|
97
|
+
!Array.isArray(targetVal)
|
|
98
|
+
) {
|
|
99
|
+
result[key] = deepMerge(
|
|
100
|
+
targetVal as Record<string, unknown>,
|
|
101
|
+
sourceVal as Record<string, unknown>
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
result[key] = sourceVal;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Merge GSWD config into config.json under the `gswd` key.
|
|
113
|
+
* - Reads existing config (preserves ALL non-gswd keys)
|
|
114
|
+
* - Deep merges: existing gswd values take precedence over defaults
|
|
115
|
+
* - Fills in missing defaults
|
|
116
|
+
* - Writes back atomically
|
|
117
|
+
*
|
|
118
|
+
* CRITICAL: Never touches any key outside `gswd`.
|
|
119
|
+
*/
|
|
120
|
+
export function mergeGswdConfig(
|
|
121
|
+
configPath: string,
|
|
122
|
+
gswdOverrides?: Partial<GswdConfig>
|
|
123
|
+
): void {
|
|
124
|
+
// Read existing config (preserves GSD keys, etc.)
|
|
125
|
+
const existing = readConfig(configPath);
|
|
126
|
+
|
|
127
|
+
// Start with defaults
|
|
128
|
+
let mergedGswd: Record<string, unknown> = { ...GSWD_CONFIG_DEFAULTS } as Record<string, unknown>;
|
|
129
|
+
|
|
130
|
+
// Apply overrides on top of defaults
|
|
131
|
+
if (gswdOverrides) {
|
|
132
|
+
mergedGswd = deepMerge(mergedGswd, gswdOverrides as Record<string, unknown>);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Apply existing user values on top (user values take precedence)
|
|
136
|
+
const existingGswd = existing.gswd;
|
|
137
|
+
if (typeof existingGswd === 'object' && existingGswd !== null && !Array.isArray(existingGswd)) {
|
|
138
|
+
mergedGswd = deepMerge(mergedGswd, existingGswd as Record<string, unknown>);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Write back: existing config with updated gswd key
|
|
142
|
+
existing.gswd = mergedGswd;
|
|
143
|
+
safeWriteJson(configPath, existing);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get GSWD config with all defaults filled in for missing fields.
|
|
148
|
+
*/
|
|
149
|
+
export function getGswdConfig(configPath: string): GswdConfig {
|
|
150
|
+
const config = readConfig(configPath);
|
|
151
|
+
const gswdRaw = config.gswd;
|
|
152
|
+
|
|
153
|
+
if (typeof gswdRaw !== 'object' || gswdRaw === null || Array.isArray(gswdRaw)) {
|
|
154
|
+
return { ...GSWD_CONFIG_DEFAULTS };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Merge defaults under existing values
|
|
158
|
+
const merged = deepMerge(
|
|
159
|
+
{ ...GSWD_CONFIG_DEFAULTS } as Record<string, unknown>,
|
|
160
|
+
gswdRaw as Record<string, unknown>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return merged as GswdConfig;
|
|
164
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Imagine Agents Module — Agent definitions, orchestration, and result collection
|
|
3
|
+
*
|
|
4
|
+
* Provides batched parallel agent spawning that respects max_parallel_agents config.
|
|
5
|
+
* Agent definitions describe the 5 research agents from GSWD_SPEC Section 11.1.
|
|
6
|
+
* Actual Task() integration happens in the workflow orchestrator (imagine.ts).
|
|
7
|
+
*
|
|
8
|
+
* Schema: GSWD_SPEC.md Section 8.2 Steps 3-4, Section 11.1
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import type { StarterBrief } from './imagine-input.js';
|
|
13
|
+
|
|
14
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface AgentDefinition {
|
|
17
|
+
name: string; // e.g., 'market-researcher'
|
|
18
|
+
definitionPath: string; // e.g., 'agents/gswd/market-researcher.md'
|
|
19
|
+
outputArtifact: string; // e.g., 'COMPETITION.md' (which artifact this feeds)
|
|
20
|
+
requiredHeadings: string[]; // Minimum output validation
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AgentResult {
|
|
24
|
+
agent: string; // Agent name
|
|
25
|
+
content: string; // Agent's output content
|
|
26
|
+
status: 'complete' | 'failed';
|
|
27
|
+
error?: string; // Error message if failed
|
|
28
|
+
duration_ms?: number; // Execution time
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SpawnFn = (prompt: string) => Promise<string>;
|
|
32
|
+
|
|
33
|
+
// ─── Agent Definitions ───────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The 5 research agents for the Imagine stage.
|
|
37
|
+
* Names match GSWD_SPEC Section 11.1 file names.
|
|
38
|
+
*/
|
|
39
|
+
export const IMAGINE_AGENTS: AgentDefinition[] = [
|
|
40
|
+
{
|
|
41
|
+
name: 'market-researcher',
|
|
42
|
+
definitionPath: 'agents/gswd/market-researcher.md',
|
|
43
|
+
outputArtifact: 'COMPETITION.md',
|
|
44
|
+
requiredHeadings: ['## Market Overview', '## Competitors'],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'icp-persona',
|
|
48
|
+
definitionPath: 'agents/gswd/icp-persona.md',
|
|
49
|
+
outputArtifact: 'ICP.md',
|
|
50
|
+
requiredHeadings: ['## ICP Profile', '## Pain Points'],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'positioning',
|
|
54
|
+
definitionPath: 'agents/gswd/positioning.md',
|
|
55
|
+
outputArtifact: 'GTM.md',
|
|
56
|
+
requiredHeadings: ['## Value Proposition'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'brainstorm-alternatives',
|
|
60
|
+
definitionPath: 'agents/gswd/brainstorm-alternatives.md',
|
|
61
|
+
outputArtifact: 'DIRECTIONS',
|
|
62
|
+
requiredHeadings: ['## Direction 1', '## Direction 2', '## Direction 3'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'devils-advocate',
|
|
66
|
+
definitionPath: 'agents/gswd/devils-advocate.md',
|
|
67
|
+
outputArtifact: 'RISKS',
|
|
68
|
+
requiredHeadings: ['## Risks'],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// ─── Prompt Building ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the prompt that will be passed to Task() for an agent.
|
|
76
|
+
*
|
|
77
|
+
* Reads agent definition file and injects StarterBrief as context.
|
|
78
|
+
*/
|
|
79
|
+
export function buildAgentPrompt(agent: AgentDefinition, brief: StarterBrief): string {
|
|
80
|
+
let definition: string;
|
|
81
|
+
try {
|
|
82
|
+
definition = fs.readFileSync(agent.definitionPath, 'utf-8');
|
|
83
|
+
} catch {
|
|
84
|
+
definition = `Agent: ${agent.name}\nRole: Research agent for ${agent.outputArtifact}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const briefJson = JSON.stringify(brief, null, 2);
|
|
88
|
+
|
|
89
|
+
return `${definition}
|
|
90
|
+
|
|
91
|
+
<starter_brief>
|
|
92
|
+
${briefJson}
|
|
93
|
+
</starter_brief>
|
|
94
|
+
|
|
95
|
+
Produce your output now. Include all required headings: ${agent.requiredHeadings.join(', ')}.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Orchestration ───────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Orchestrate agents in batches respecting maxParallel concurrency limit.
|
|
102
|
+
*
|
|
103
|
+
* Splits agents into batches of maxParallel size, executes each batch
|
|
104
|
+
* concurrently, collects results. Failed agents are marked with status: 'failed'
|
|
105
|
+
* but do not crash the orchestration.
|
|
106
|
+
*
|
|
107
|
+
* @param agents - Array of agent definitions to spawn
|
|
108
|
+
* @param maxParallel - Maximum concurrent agents (from config.max_parallel_agents)
|
|
109
|
+
* @param brief - StarterBrief input for all agents
|
|
110
|
+
* @param spawnFn - Function that spawns an agent (Task() wrapper)
|
|
111
|
+
* @returns All agent results including failures
|
|
112
|
+
*/
|
|
113
|
+
export async function orchestrateAgents(
|
|
114
|
+
agents: AgentDefinition[],
|
|
115
|
+
maxParallel: number,
|
|
116
|
+
brief: StarterBrief,
|
|
117
|
+
spawnFn: SpawnFn,
|
|
118
|
+
): Promise<AgentResult[]> {
|
|
119
|
+
const results: AgentResult[] = [];
|
|
120
|
+
|
|
121
|
+
// Split into batches
|
|
122
|
+
for (let i = 0; i < agents.length; i += maxParallel) {
|
|
123
|
+
const batch = agents.slice(i, i + maxParallel);
|
|
124
|
+
|
|
125
|
+
// Execute batch in parallel
|
|
126
|
+
const batchPromises = batch.map(async (agent): Promise<AgentResult> => {
|
|
127
|
+
const start = Date.now();
|
|
128
|
+
try {
|
|
129
|
+
const prompt = buildAgentPrompt(agent, brief);
|
|
130
|
+
const content = await spawnFn(prompt);
|
|
131
|
+
return {
|
|
132
|
+
agent: agent.name,
|
|
133
|
+
content,
|
|
134
|
+
status: 'complete',
|
|
135
|
+
duration_ms: Date.now() - start,
|
|
136
|
+
};
|
|
137
|
+
} catch (err: unknown) {
|
|
138
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
139
|
+
return {
|
|
140
|
+
agent: agent.name,
|
|
141
|
+
content: '',
|
|
142
|
+
status: 'failed',
|
|
143
|
+
error: message,
|
|
144
|
+
duration_ms: Date.now() - start,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const batchResults = await Promise.all(batchPromises);
|
|
150
|
+
results.push(...batchResults);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Imagine Gate Module — Decision gate validation for DECISIONS.md
|
|
3
|
+
*
|
|
4
|
+
* Pure function: validates DECISIONS.md against hard gate requirements.
|
|
5
|
+
* No side effects, no file writes, no state mutations.
|
|
6
|
+
*
|
|
7
|
+
* Gate requirements (GSWD_SPEC Section 3.3, Appendix A1):
|
|
8
|
+
* - ## Frozen Decisions: >= 8 items
|
|
9
|
+
* - ## Success Metrics: 1-3 items
|
|
10
|
+
* - ## Out of Scope: >= 1 item
|
|
11
|
+
* - ## Risks & Mitigations: >= 5 items
|
|
12
|
+
* - ## Open Questions: >= 0 items (section must exist)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { validateHeadings, extractHeadingContent } from './parse.js';
|
|
16
|
+
|
|
17
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface InsufficientCount {
|
|
20
|
+
section: string;
|
|
21
|
+
required: number;
|
|
22
|
+
actual: number;
|
|
23
|
+
constraint?: string; // e.g., "1-3" for metrics range
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GateResult {
|
|
27
|
+
passed: boolean;
|
|
28
|
+
missing_sections: string[];
|
|
29
|
+
insufficient_counts: InsufficientCount[];
|
|
30
|
+
summary: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count list items in a section.
|
|
37
|
+
*
|
|
38
|
+
* Counts lines starting with:
|
|
39
|
+
* - `- ` (bullet)
|
|
40
|
+
* - `* ` (bullet)
|
|
41
|
+
* - `N. ` or `N) ` (numbered)
|
|
42
|
+
* - `### ` (sub-headings as items)
|
|
43
|
+
*
|
|
44
|
+
* Returns 0 for empty/null/undefined content.
|
|
45
|
+
*/
|
|
46
|
+
export function countListItems(content: string | null | undefined): number {
|
|
47
|
+
if (!content) return 0;
|
|
48
|
+
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
let count = 0;
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (/^[-*]\s+/.test(trimmed)) {
|
|
55
|
+
count++;
|
|
56
|
+
} else if (/^\d+[.)]\s+/.test(trimmed)) {
|
|
57
|
+
count++;
|
|
58
|
+
} else if (/^###\s+/.test(trimmed)) {
|
|
59
|
+
count++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return count;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Gate Validation ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate DECISIONS.md against the hard gate requirements.
|
|
70
|
+
*
|
|
71
|
+
* Step 1: Required headings (via parse.ts validateHeadings)
|
|
72
|
+
* Step 2: Item counts per section (via parse.ts extractHeadingContent + countListItems)
|
|
73
|
+
* Step 3: Build structured result
|
|
74
|
+
*
|
|
75
|
+
* Returns: GateResult with passed/failed status, missing sections, and count issues.
|
|
76
|
+
*/
|
|
77
|
+
export function validateDecisionGate(decisionsContent: string): GateResult {
|
|
78
|
+
// Step 1: Required headings
|
|
79
|
+
const headingResult = validateHeadings(decisionsContent, 'DECISIONS.md');
|
|
80
|
+
|
|
81
|
+
// Step 2: Count items per section
|
|
82
|
+
const insufficient: InsufficientCount[] = [];
|
|
83
|
+
|
|
84
|
+
// Frozen Decisions: >= 8
|
|
85
|
+
const frozenSection = extractHeadingContent(decisionsContent, '## Frozen Decisions');
|
|
86
|
+
const frozenCount = countListItems(frozenSection);
|
|
87
|
+
if (frozenCount < 8) {
|
|
88
|
+
insufficient.push({
|
|
89
|
+
section: 'Frozen Decisions',
|
|
90
|
+
required: 8,
|
|
91
|
+
actual: frozenCount,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Success Metrics: 1-3
|
|
96
|
+
const metricsSection = extractHeadingContent(decisionsContent, '## Success Metrics');
|
|
97
|
+
const metricsCount = countListItems(metricsSection);
|
|
98
|
+
if (metricsCount < 1 || metricsCount > 3) {
|
|
99
|
+
insufficient.push({
|
|
100
|
+
section: 'Success Metrics',
|
|
101
|
+
required: metricsCount < 1 ? 1 : 3,
|
|
102
|
+
actual: metricsCount,
|
|
103
|
+
constraint: '1-3',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Out of Scope: >= 1
|
|
108
|
+
const outOfScopeSection = extractHeadingContent(decisionsContent, '## Out of Scope');
|
|
109
|
+
const outOfScopeCount = countListItems(outOfScopeSection);
|
|
110
|
+
if (outOfScopeCount < 1) {
|
|
111
|
+
insufficient.push({
|
|
112
|
+
section: 'Out of Scope',
|
|
113
|
+
required: 1,
|
|
114
|
+
actual: outOfScopeCount,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Risks & Mitigations: >= 5
|
|
119
|
+
const risksSection = extractHeadingContent(decisionsContent, '## Risks & Mitigations');
|
|
120
|
+
const risksCount = countListItems(risksSection);
|
|
121
|
+
if (risksCount < 5) {
|
|
122
|
+
insufficient.push({
|
|
123
|
+
section: 'Risks & Mitigations',
|
|
124
|
+
required: 5,
|
|
125
|
+
actual: risksCount,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Open Questions: >= 0 (section must exist, but can be empty)
|
|
130
|
+
// No count check needed — heading presence is sufficient
|
|
131
|
+
|
|
132
|
+
// Step 3: Build result
|
|
133
|
+
const passed = headingResult.valid && insufficient.length === 0;
|
|
134
|
+
const totalIssues = headingResult.missing.length + insufficient.length;
|
|
135
|
+
|
|
136
|
+
let summary: string;
|
|
137
|
+
if (passed) {
|
|
138
|
+
summary = 'PASS: All 5 sections present, counts valid';
|
|
139
|
+
} else {
|
|
140
|
+
const issues: string[] = [];
|
|
141
|
+
if (headingResult.missing.length > 0) {
|
|
142
|
+
issues.push(`${headingResult.missing.length} missing heading(s)`);
|
|
143
|
+
}
|
|
144
|
+
if (insufficient.length > 0) {
|
|
145
|
+
issues.push(`${insufficient.length} insufficient count(s)`);
|
|
146
|
+
}
|
|
147
|
+
summary = `FAIL: ${totalIssues} issue(s) — ${issues.join(', ')}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
passed,
|
|
152
|
+
missing_sections: headingResult.missing,
|
|
153
|
+
insufficient_counts: insufficient,
|
|
154
|
+
summary,
|
|
155
|
+
};
|
|
156
|
+
}
|