Plan: Automated Content Scaffolding — Value Streams & Management Practices
Version: 1.1 Date: 2026-02-17 Status: Implemented Related:
- Plan 01 Governance Model & Content Structure
- CODEOWNERS Deferred
- Knowledge Standards
- How to Contribute
- Content Types Taxonomy
1. Executive Summary
This plan introduces a GitHub-native governance automation system that transforms the creation of Value Streams and Management Practices from a manual, error-prone process into a structured, validated, and largely automated workflow. The entry point is a GitHub Issue Form (structured YAML template); the exit point is a fully scaffolded Pull Request with all files, folder structures, frontmatter, cross-references, CODEOWNERS entries, and an AI-powered MECE analysis — ready for human review.
Key Benefits:
- Zero manual scaffolding — folder trees, stub files, frontmatter, and cross-references are generated automatically from structured issue inputs
- Governance enforcement — deterministic validation catches naming violations, code collisions, and structural errors before any human reviews
- MECE integrity — AI-powered semantic overlap detection compares the proposed VS/Practice purpose against all existing ones, flagging potential boundary conflicts
- CODEOWNERS readiness — the automation scaffolds CODEOWNERS entries proactively, with warnings when the referenced GitHub Team doesn’t yet exist
- Audit trail — every VS/Practice creation is traced from issue → PR → merge, with the full validation report embedded in the PR body
Critical Dependencies:
- GitHub Actions workflows with
issuesandpull_requestevent triggers - GitHub Models API access for AI-powered MECE checks (free for Actions)
- Node.js 22 runtime (already configured in
deploy.yml) - Existing repository content structure (
content/01 Value Streams/,content/02 Guilds/)
Scope: Value Stream and Management Practice creation only. Guild creation, Product creation, and decision scaffolding are out of scope and may be addressed in future plans.
2. Architecture / Context Overview
Current State
| Aspect | Current Behavior |
|---|---|
| VS/Practice creation | Manual: copy template from content/metadata/templater/, rename files, populate frontmatter, create folders, submit PR |
| Structural validation | None — reviewers manually check folder structure, naming, frontmatter |
| MECE checking | Manual — author and reviewer assess overlap by reading existing content |
| CODEOWNERS | Deferred — no file exists; planned structure documented in docs/CODEOWNERS_DEFERRED.md |
| Issue tracking | No issue templates — creation requests are ad-hoc |
| CI validation | None for content — only deploy.yml builds the site on push to main |
Proposed State
| Aspect | Proposed Behavior |
|---|---|
| VS/Practice creation | Automated: fill GitHub Issue Form → Actions workflow scaffolds all files → PR created |
| Structural validation | Deterministic: Node.js scripts validate naming, codes, folder structure, cross-references |
| MECE checking | AI-assisted: GitHub Models API compares proposed purpose against all existing ones |
| CODEOWNERS | Auto-managed: scaffolding workflow appends entries, warns if team doesn’t exist |
| Issue tracking | Structured YAML issue forms with validation fields and auto-labels |
| CI validation | Content validation workflow runs on all content/** PRs |
Architecture Diagram
┌──────────────────────────┐
│ GitHub Issue Form │
│ (YAML Issue Template) │
└───────────┬──────────────┘
│ issues.opened + label
▼
┌──────────────────────────┐
│ GitHub Actions Workflow │
│ │
│ ┌─ parse-issue.mjs │
│ ├─ validate-structure.mjs│
│ ├─ mece-check.mjs │
│ ├─ scaffold-files.mjs │
│ └─ update-codeowners.mjs │
└───────────┬──────────────┘
│
▼
┌──────────────────────────┐
│ Pull Request │
│ - Scaffolded files │
│ - Validation report │
│ - MECE analysis │
│ - CODEOWNERS update │
│ - Linked to issue │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ validate-content.yml │
│ (CI gate on all PRs) │
└──────────────────────────┘
Key Tradeoffs
| Decision | Choice | Rationale |
|---|---|---|
| Entry point | GitHub Issue Forms | Centralizes governance, creates audit trail, accessible to non-technical users, no local tooling required |
| Template ownership | Scripts own templates (inline strings) | Full control over output; no Templater syntax parsing; single source of truth; supersedes TMP01/TMP03 |
| AI MECE mode | Advisory (not blocking) | Semantic overlap is inherently fuzzy; humans make the final call; AI provides evidence |
| Script language | Node.js ESM (.mjs) | Matches existing Quartz/Node.js stack and "type": "module" in package.json |
| CODEOWNERS timing | Scaffold proactively with warnings | Entries are ready to activate when GitHub Teams are created; warnings prevent silent failures |
| Issue-to-PR flow | Fully automated (no manual trigger) | Minimizes friction; the PR itself is the review/approval gate |
3. Implementation Steps
Step 1: Create Issue Body Parser
Goal: A reusable utility that extracts structured field values from GitHub Issue Form markdown bodies.
File: scripts/parse-issue.mjs
GitHub Issue Forms produce markdown with predictable ### FieldName\n\nValue patterns. This script parses those into a structured object.
// scripts/parse-issue.mjs
// Parses GitHub Issue Form body into structured key-value object.
//
// GitHub Issue Forms emit markdown in this pattern:
// ### Field Label
//
// User's value
//
// ### Next Field Label
//
// Next value
/**
* @param {string} body - Raw issue body markdown
* @returns {Record<string, string>} - Map of field label → trimmed value
*/
export function parseIssueBody(body) {
const fields = {};
const sections = body.split(/^### /m).filter(Boolean);
for (const section of sections) {
const newlineIndex = section.indexOf("\n");
if (newlineIndex === -1) continue;
const label = section.slice(0, newlineIndex).trim();
const value = section.slice(newlineIndex + 1).trim();
// Skip "_No response_" default for optional fields
if (value && value !== "_No response_") {
fields[label] = value;
}
}
return fields;
}
/**
* Converts a field value containing a comma-separated or newline-separated
* list into an array of trimmed, non-empty strings.
* @param {string} value
* @returns {string[]}
*/
export function parseList(value) {
if (!value) return [];
return value
.split(/[,\n]/)
.map((item) => item.replace(/^[-*]\s*/, "").trim())
.filter(Boolean);
}
/**
* Extracts the guild code (e.g., "GL03") and full guild folder name
* (e.g., "GL03 Delivery Guild") from a dropdown selection.
* @param {string} selection - e.g., "GL03 Delivery Guild"
* @returns {{ code: string, folderName: string }}
*/
export function parseGuildSelection(selection) {
const match = selection.match(/^(GL\d{2})\s+(.+)$/);
if (!match) throw new Error(`Invalid guild selection: "${selection}"`);
return { code: match[1], folderName: selection.trim() };
}Verification: Unit test with sample issue body markdown (see Step 9).
Step 2: Create Structural Validation Script
Goal: Deterministic validation of naming conventions, code uniqueness, folder collisions, and cross-reference integrity.
File: scripts/validate-structure.mjs
// scripts/validate-structure.mjs
import { readdir, readFile, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import matter from "gray-matter";
const CONTENT_DIR = resolve(process.cwd(), "content");
const VS_DIR = join(CONTENT_DIR, "01 Value Streams");
const GUILDS_DIR = join(CONTENT_DIR, "02 Guilds");
/**
* Scans content/01 Value Streams/ for existing VS codes.
* @returns {Promise<string[]>} e.g., ["VS01", "VS02", ...]
*/
export async function getExistingVSCodes() {
const entries = await readdir(VS_DIR, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && /^VS\d{2}\s/.test(e.name))
.map((e) => e.name.match(/^(VS\d{2})/)[1])
.sort();
}
/**
* Scans all practice README.md files for existing practice IDs.
* @returns {Promise<Array<{ id: string, guild: string, name: string }>>}
*/
export async function getExistingPracticeIds() {
const results = [];
const guilds = await readdir(GUILDS_DIR, { withFileTypes: true });
for (const guild of guilds.filter(
(g) => g.isDirectory() && /^GL\d{2}/.test(g.name),
)) {
const practicesDir = join(GUILDS_DIR, guild.name, "Practices");
try {
const practices = await readdir(practicesDir, { withFileTypes: true });
for (const practice of practices.filter((p) => p.isDirectory())) {
const overviewPath = join(practicesDir, practice.name, "README.md");
try {
const content = await readFile(overviewPath, "utf-8");
const { data } = matter(content);
if (data["practice-id"]) {
results.push({
id: data["practice-id"],
guild: guild.name,
name: practice.name,
});
}
} catch {
// No overview file — skip
}
}
} catch {
// No Practices directory — skip
}
}
return results;
}
/**
* Validates a VS code for format and uniqueness.
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
export async function validateVSCode(code) {
const errors = [];
if (!/^VS\d{2}$/.test(code)) {
errors.push(
`VS code "${code}" does not match required format VS## (e.g., VS08)`,
);
}
const existing = await getExistingVSCodes();
if (existing.includes(code)) {
errors.push(
`VS code "${code}" already exists. Existing codes: ${existing.join(", ")}`,
);
}
return { valid: errors.length === 0, errors };
}
/**
* Validates a practice ID for format and uniqueness.
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
export async function validatePracticeId(id) {
const errors = [];
if (!/^[A-Z]{2,5}-[A-Z]{2,5}-\d{2}$/.test(id)) {
errors.push(
`Practice ID "${id}" does not match required format [A-Z]{2,5}-[A-Z]{2,5}-## (e.g., DEL-CHG-01)`,
);
}
const existing = await getExistingPracticeIds();
const collision = existing.find((e) => e.id === id);
if (collision) {
errors.push(
`Practice ID "${id}" already exists in ${collision.guild} / ${collision.name}`,
);
}
return { valid: errors.length === 0, errors };
}
/**
* Validates naming conventions for a VS or Practice name.
* @param {string} name
* @param {"value-stream" | "practice"} type
* @returns {{ valid: boolean, errors: string[] }}
*/
export function validateNamingConventions(name, type) {
const errors = [];
// No special characters except hyphens and spaces
if (/[^a-zA-Z0-9\s\-]/.test(name)) {
errors.push(
`Name "${name}" contains special characters. Only letters, numbers, spaces, and hyphens are allowed.`,
);
}
// Practice names must end with "Management"
if (type === "practice" && !name.endsWith("Management")) {
errors.push(
`Practice name "${name}" must end with "Management" (e.g., "Change Management")`,
);
}
// Title Case check (first letter of each word capitalised)
const words = name.split(/\s+/);
const nonTitleCase = words.filter(
(w) => w.length > 0 && w[0] !== w[0].toUpperCase(),
);
if (nonTitleCase.length > 0) {
errors.push(
`Name "${name}" should be Title Case. These words need capitalising: ${nonTitleCase.join(", ")}`,
);
}
return { valid: errors.length === 0, errors };
}
/**
* Checks whether a folder path already exists (collision detection).
* @param {string} relativePath - Path relative to content/
* @returns {Promise<{ exists: boolean }>}
*/
export async function checkFolderCollision(relativePath) {
const fullPath = join(CONTENT_DIR, relativePath);
try {
const s = await stat(fullPath);
return { exists: s.isDirectory() };
} catch {
return { exists: false };
}
}
/**
* Validates that a guild folder exists.
* @param {string} guildFolderName - e.g., "GL03 Delivery Guild"
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
export async function validateGuildExists(guildFolderName) {
const errors = [];
const fullPath = join(GUILDS_DIR, guildFolderName);
try {
await stat(fullPath);
} catch {
errors.push(
`Guild folder "${guildFolderName}" does not exist in content/02 Guilds/`,
);
}
return { valid: errors.length === 0, errors };
}
/**
* Validates that referenced practices exist somewhere in the guild tree.
* @param {string[]} practiceNames
* @returns {Promise<{ valid: boolean, found: string[], missing: string[] }>}
*/
export async function validatePracticesExist(practiceNames) {
const found = [];
const missing = [];
for (const name of practiceNames) {
let exists = false;
const guilds = await readdir(GUILDS_DIR, { withFileTypes: true });
for (const guild of guilds.filter(
(g) => g.isDirectory() && /^GL\d{2}/.test(g.name),
)) {
const practicesDir = join(GUILDS_DIR, guild.name, "Practices");
try {
const practices = await readdir(practicesDir, { withFileTypes: true });
if (practices.some((p) => p.isDirectory() && p.name === name)) {
exists = true;
break;
}
} catch {
// No Practices directory — skip
}
}
(exists ? found : missing).push(name);
}
return { valid: missing.length === 0, found, missing };
}
/**
* Suggests the next available VS code.
* @returns {Promise<string>}
*/
export async function suggestNextVSCode() {
const existing = await getExistingVSCodes();
if (existing.length === 0) return "VS01";
const maxNum = Math.max(
...existing.map((c) => parseInt(c.replace("VS", ""), 10)),
);
return `VS${String(maxNum + 1).padStart(2, "0")}`;
}
/**
* Runs all validations for a Value Stream creation request.
* @param {{ code: string, name: string, relatedPractices: string[] }} inputs
* @returns {Promise<{ passed: boolean, results: Array<{ check: string, passed: boolean, detail: string }> }>}
*/
export async function validateValueStream(inputs) {
const results = [];
// Code format + uniqueness
const codeResult = await validateVSCode(inputs.code);
results.push({
check: "VS Code Format & Uniqueness",
passed: codeResult.valid,
detail: codeResult.valid
? `${inputs.code} is valid and unique`
: codeResult.errors.join("; "),
});
// Naming conventions
const nameResult = validateNamingConventions(inputs.name, "value-stream");
results.push({
check: "Naming Convention",
passed: nameResult.valid,
detail: nameResult.valid
? `"${inputs.name}" follows naming conventions`
: nameResult.errors.join("; "),
});
// Folder collision
const folderPath = `01 Value Streams/${inputs.code} ${inputs.name}`;
const collision = await checkFolderCollision(folderPath);
results.push({
check: "No Folder Collision",
passed: !collision.exists,
detail: collision.exists
? `Folder "content/${folderPath}" already exists`
: `Path "content/${folderPath}" is available`,
});
// Related practices exist
if (inputs.relatedPractices.length > 0) {
const practicesResult = await validatePracticesExist(
inputs.relatedPractices,
);
results.push({
check: "Related Practices Exist",
passed: practicesResult.valid,
detail: practicesResult.valid
? `All ${practicesResult.found.length} referenced practices found`
: `Missing practices: ${practicesResult.missing.join(", ")}`,
});
} else {
results.push({
check: "Related Practices Exist",
passed: true,
detail: "No related practices specified (optional field)",
});
}
return {
passed: results.every((r) => r.passed),
results,
};
}
/**
* Runs all validations for a Management Practice creation request.
* @param {{ id: string, name: string, guildFolder: string, dependencies: string[], dependents: string[] }} inputs
* @returns {Promise<{ passed: boolean, results: Array<{ check: string, passed: boolean, detail: string }> }>}
*/
export async function validatePractice(inputs) {
const results = [];
// Practice ID format + uniqueness
const idResult = await validatePracticeId(inputs.id);
results.push({
check: "Practice ID Format & Uniqueness",
passed: idResult.valid,
detail: idResult.valid
? `${inputs.id} is valid and unique`
: idResult.errors.join("; "),
});
// Naming conventions
const nameResult = validateNamingConventions(inputs.name, "practice");
results.push({
check: "Naming Convention",
passed: nameResult.valid,
detail: nameResult.valid
? `"${inputs.name}" follows naming conventions`
: nameResult.errors.join("; "),
});
// Parent guild exists
const guildResult = await validateGuildExists(inputs.guildFolder);
results.push({
check: "Parent Guild Exists",
passed: guildResult.valid,
detail: guildResult.valid
? `Guild folder "${inputs.guildFolder}" exists`
: guildResult.errors.join("; "),
});
// Folder collision
const folderPath = `02 Guilds/${inputs.guildFolder}/Practices/${inputs.name}`;
const collision = await checkFolderCollision(folderPath);
results.push({
check: "No Folder Collision",
passed: !collision.exists,
detail: collision.exists
? `Folder "content/${folderPath}" already exists`
: `Path "content/${folderPath}" is available`,
});
// Dependencies exist
const allRefs = [...inputs.dependencies, ...inputs.dependents];
if (allRefs.length > 0) {
const refsResult = await validatePracticesExist(allRefs);
results.push({
check: "Referenced Practices Exist",
passed: refsResult.valid,
detail: refsResult.valid
? `All ${refsResult.found.length} referenced practices found`
: `Missing practices: ${refsResult.missing.join(", ")}`,
});
} else {
results.push({
check: "Referenced Practices Exist",
passed: true,
detail: "No dependencies/dependents specified (optional fields)",
});
}
return {
passed: results.every((r) => r.passed),
results,
};
}Dependencies: gray-matter (already in Quartz’s dependency tree — verify with npm ls gray-matter; if not present, add as a dev dependency from a Windows terminal).
Verification: Run unit tests (Step 9); manually run against known VS codes and practice IDs:
node -e "import('./scripts/validate-structure.mjs').then(m => m.getExistingVSCodes().then(console.log))"
# Expected: ["VS01", "VS02", "VS03", "VS04", "VS05", "VS06", "VS07"]Step 3: Create File Scaffolding Script
Goal: Generate the complete folder tree and populated markdown files for a new Value Stream or Management Practice. This script is the source of truth for templates, superseding the Obsidian Templater files TMP01 and TMP03.
File: scripts/scaffold-files.mjs
// scripts/scaffold-files.mjs
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const CONTENT_DIR = resolve(process.cwd(), "content");
/**
* Creates a directory and all parents. Writes a .gitkeep if the directory
* is intended to be empty (git doesn't track empty directories).
*/
async function ensureDir(dirPath, { gitkeep = false } = {}) {
await mkdir(dirPath, { recursive: true });
if (gitkeep) {
await writeFile(join(dirPath, ".gitkeep"), "");
}
}
// ─── Value Stream Templates ────────────────────────────────────────────────
function vsOverviewTemplate(inputs) {
const today = new Date().toISOString().slice(0, 10);
return `---
publish: true
page-status: draft
created: ${today}
owner: ${inputs.ownerSlug}
vs-code: ${inputs.code}
type: value-stream-overview
---
# ${inputs.code} ${inputs.name}
## Purpose and Outcomes
${inputs.purpose}
## Customers and Stakeholders
### Primary Customer
${inputs.primaryCustomer}
### Key Stakeholders
${inputs.stakeholders || "- [Stakeholder] - [Their interest]"}
## Boundaries
### Start Trigger
${inputs.startTrigger}
### End Trigger
${inputs.endTrigger}
### Typical Duration
${inputs.typicalDuration || "[To be determined]"}
## Owner and Governance Model
- **Value Stream Owner:** ${inputs.owner}
- **Governance Team:** \`@calab-ai/${inputs.teamSlug}\`
- **Decision Authority:** [To be defined]
- **Escalation Path:** [To be defined]
## Related Practices
This Value Stream coordinates the following practices (see [[05 Practice Links]] for detailed mappings):
${inputs.relatedPractices.map((p) => `- [[${p}]]`).join("\n") || "- [No practices linked yet]"}
**Note:** This Value Stream does NOT own practice artefacts. All policies, processes, and procedures remain owned by their respective practices.
## Value Stream Documentation
- [[01 Value Stream Map]] - Visual flow and stages
- [[02 Governance & Roles]] - RACI and decision rights
- [[03 Metrics]] - Performance indicators and targets
- [[04 Interfaces]] - Dependencies and handoffs
- [[05 Practice Links]] - Detailed practice relationships
## Review Cadence
- **VS Performance Review:** Monthly
- **VS Design Review:** Quarterly
- **Owner:** ${inputs.owner}
## Change Log
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | ${today} | ${inputs.author || "[Author]"} | Initial creation (auto-scaffolded) |
`;
}
function vsStubTemplate(title, pageStatus = "draft") {
const today = new Date().toISOString().slice(0, 10);
return `---
page-status: ${pageStatus}
created: ${today}
---
# ${title}
[To be completed — this page was auto-scaffolded. See the [[README]] for context.]
`;
}
function vsPracticeLinksTemplate(inputs) {
const today = new Date().toISOString().slice(0, 10);
return `---
page-status: draft
created: ${today}
---
# Practice Links
This document maps the management practices that participate in the **${inputs.code} ${inputs.name}** value stream.
## Coordinated Practices
${inputs.relatedPractices.length > 0 ? inputs.relatedPractices.map((p) => `### ${p}\n\n- **Role in this VS:** [To be defined]\n- **Key handoffs:** [To be defined]\n- **Interfaces:** [To be defined]\n`).join("\n") : "[No practices linked yet — update this page as practice relationships are established.]"}
## Practice Relationship Principles
- Value Streams coordinate practices but do NOT own practice artefacts
- Practice artefacts (policies, processes, procedures) remain owned by their respective Guilds
- This mapping defines how practices contribute to end-to-end value delivery
`;
}
/**
* Scaffolds a complete Value Stream folder structure.
* @param {object} inputs
* @param {string} inputs.code - e.g., "VS08"
* @param {string} inputs.name - e.g., "Plan to Innovate"
* @param {string} inputs.purpose - 2-3 sentence purpose description
* @param {string} inputs.primaryCustomer
* @param {string} inputs.stakeholders - Raw stakeholder text
* @param {string} inputs.startTrigger
* @param {string} inputs.endTrigger
* @param {string} inputs.typicalDuration
* @param {string} inputs.owner - VS Owner name/role
* @param {string} inputs.ownerSlug - Frontmatter owner slug
* @param {string} inputs.teamSlug - GitHub team slug
* @param {string[]} inputs.relatedPractices - Practice names
* @param {string} [inputs.author] - Author name for change log
* @returns {Promise<string[]>} - List of created file paths (relative to repo root)
*/
export async function scaffoldValueStream(inputs) {
const folderName = `${inputs.code} ${inputs.name}`;
const vsDir = join(CONTENT_DIR, "01 Value Streams", folderName);
const createdFiles = [];
// Create directory structure
await ensureDir(vsDir);
await ensureDir(join(vsDir, "metadata", "diagrams"), { gitkeep: true });
await ensureDir(join(vsDir, "metadata", "plans"), { gitkeep: true });
await ensureDir(join(vsDir, "metadata", "specs"), { gitkeep: true });
// Write files
const files = [
["README.md", vsOverviewTemplate(inputs)],
["01 Value Stream Map.md", vsStubTemplate("Value Stream Map")],
["02 Governance & Roles.md", vsStubTemplate("Governance & Roles")],
["03 Metrics.md", vsStubTemplate("Metrics")],
["04 Interfaces.md", vsStubTemplate("Interfaces")],
["05 Practice Links.md", vsPracticeLinksTemplate(inputs)],
];
for (const [filename, content] of files) {
const filePath = join(vsDir, filename);
await writeFile(filePath, content, "utf-8");
createdFiles.push(`content/01 Value Streams/${folderName}/${filename}`);
}
// Track .gitkeep files
createdFiles.push(
`content/01 Value Streams/${folderName}/metadata/diagrams/.gitkeep`,
`content/01 Value Streams/${folderName}/metadata/plans/.gitkeep`,
`content/01 Value Streams/${folderName}/metadata/specs/.gitkeep`,
);
return createdFiles;
}
// ─── Management Practice Templates ────────────────────────────────────────
function practiceOverviewTemplate(inputs) {
const today = new Date().toISOString().slice(0, 10);
return `---
page-status: draft
created: ${today}
owner: ${inputs.ownerSlug}
practice-id: ${inputs.id}
guild: ${inputs.guildFolder}
type: practice-overview
---
# ${inputs.name}
## Metadata
- **Practice ID:** ${inputs.id}
- **Status:** Draft
- **Version:** 1.0
- **Owner Role:** ${inputs.ownerRole}
- **Guild:** [[${inputs.guildFolder}]]
## Purpose and Objectives
${inputs.purpose}
## Scope
### In Scope
${inputs.inScope || "- [To be defined]"}
### Out of Scope
${inputs.outOfScope || "- [To be defined]"}
## Interfaces
### Dependencies (Practices We Depend On)
${inputs.dependencies.length > 0 ? inputs.dependencies.map((p) => `- [[${p}]] - [Relationship to be defined]`).join("\n") : "- [No dependencies identified yet]"}
### Dependents (Practices That Depend On Us)
${inputs.dependents.length > 0 ? inputs.dependents.map((p) => `- [[${p}]] - [Relationship to be defined]`).join("\n") : "- [No dependents identified yet]"}
## Related Practices and Resources
### Related Practices
- [To be defined]
### Key Processes
- [To be defined — add links to documents in 02 Processes/]
### Key Templates
- [To be defined — add links to documents in 05 Templates/]
## KPIs and Success Signals
- **KPI 1:** [Metric and target]
- **KPI 2:** [Metric and target]
- **Success Signal 1:** [Qualitative indicator]
- **Success Signal 2:** [Qualitative indicator]
## Review Cadence
- **Practice Review:** Monthly
- **Artefact Review:** Quarterly
- **Owner:** ${inputs.ownerRole}
## Change Log
| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | ${today} | ${inputs.author || "[Author]"} | Initial creation (auto-scaffolded) |
`;
}
/**
* Scaffolds a complete Management Practice folder structure.
* @param {object} inputs
* @param {string} inputs.name - e.g., "Change Management"
* @param {string} inputs.id - e.g., "DEL-CHG-01"
* @param {string} inputs.guildFolder - e.g., "GL03 Delivery Guild"
* @param {string} inputs.purpose - 2-3 sentence purpose
* @param {string} inputs.inScope - Raw in-scope text
* @param {string} inputs.outOfScope - Raw out-of-scope text
* @param {string} inputs.ownerRole - e.g., "Change Management Lead"
* @param {string} inputs.ownerSlug - Frontmatter owner slug
* @param {string[]} inputs.dependencies - Practice names
* @param {string[]} inputs.dependents - Practice names
* @param {string} [inputs.author] - Author name for change log
* @returns {Promise<string[]>} - List of created file paths (relative to repo root)
*/
export async function scaffoldPractice(inputs) {
const practiceDir = join(
CONTENT_DIR,
"02 Guilds",
inputs.guildFolder,
"Practices",
inputs.name,
);
const createdFiles = [];
const relPath = `content/02 Guilds/${inputs.guildFolder}/Practices/${inputs.name}`;
// Create directory structure
await ensureDir(practiceDir);
const artefactDirs = [
"01 Policies",
"02 Processes",
"03 Procedures",
"04 Guides",
"05 Templates",
];
for (const dir of artefactDirs) {
await ensureDir(join(practiceDir, dir), { gitkeep: true });
createdFiles.push(`${relPath}/${dir}/.gitkeep`);
}
await ensureDir(join(practiceDir, "metadata", "diagrams"), { gitkeep: true });
await ensureDir(join(practiceDir, "metadata", "plans"), { gitkeep: true });
await ensureDir(join(practiceDir, "metadata", "specs"), { gitkeep: true });
createdFiles.push(
`${relPath}/metadata/diagrams/.gitkeep`,
`${relPath}/metadata/plans/.gitkeep`,
`${relPath}/metadata/specs/.gitkeep`,
);
// Write overview
const overviewPath = join(practiceDir, "README.md");
await writeFile(overviewPath, practiceOverviewTemplate(inputs), "utf-8");
createdFiles.unshift(`${relPath}/README.md`);
return createdFiles;
}Verification: Run scaffolding in a temp directory and inspect output structure:
# Dry-run test (uses temp dir, not actual content/)
node -e "
import { scaffoldValueStream } from './scripts/scaffold-files.mjs';
const files = await scaffoldValueStream({
code: 'VS99', name: 'Test Stream', purpose: 'Test purpose.',
primaryCustomer: 'Test customer', stakeholders: '',
startTrigger: 'Test trigger', endTrigger: 'Test end',
typicalDuration: '1 week', owner: 'Test Owner',
ownerSlug: 'test-owner', teamSlug: 'test-team',
relatedPractices: ['Project Mgmt'], author: 'Test'
});
console.log(files);
"Step 4: Create MECE Check Script
Goal: AI-powered semantic overlap detection using the GitHub Models API.
File: scripts/mece-check.mjs
// scripts/mece-check.mjs
import { readdir, readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
const CONTENT_DIR = resolve(process.cwd(), "content");
const VS_DIR = join(CONTENT_DIR, "01 Value Streams");
const GUILDS_DIR = join(CONTENT_DIR, "02 Guilds");
/**
* Extracts the "Purpose" section from a markdown file.
* Looks for ## Purpose, ## Purpose and Outcomes, or ## Purpose and Objectives.
* Returns the text until the next ## heading.
*/
function extractPurposeSection(markdown) {
const match = markdown.match(
/^##\s+Purpose(?:\s+and\s+(?:Outcomes|Objectives))?\s*\n([\s\S]*?)(?=\n##\s|\n---\s*$|$)/m,
);
return match ? match[1].trim() : "";
}
/**
* Collects purpose descriptions from all existing Value Stream overviews.
* @returns {Promise<Array<{ code: string, name: string, purpose: string }>>}
*/
export async function collectExistingVSPurposes() {
const results = [];
const entries = await readdir(VS_DIR, { withFileTypes: true });
for (const entry of entries.filter(
(e) => e.isDirectory() && /^VS\d{2}/.test(e.name),
)) {
const overviewPath = join(VS_DIR, entry.name, "README.md");
try {
const content = await readFile(overviewPath, "utf-8");
const purpose = extractPurposeSection(content);
const codeMatch = entry.name.match(/^(VS\d{2})\s+(.+)$/);
if (codeMatch && purpose) {
results.push({ code: codeMatch[1], name: codeMatch[2], purpose });
}
} catch {
// No overview — skip
}
}
return results;
}
/**
* Collects purpose descriptions from all existing Practice overviews.
* @param {string} [guildFilter] - Optional guild folder name to filter by
* @returns {Promise<Array<{ guild: string, name: string, purpose: string }>>}
*/
export async function collectExistingPracticePurposes(guildFilter) {
const results = [];
const guilds = await readdir(GUILDS_DIR, { withFileTypes: true });
for (const guild of guilds.filter(
(g) => g.isDirectory() && /^GL\d{2}/.test(g.name),
)) {
if (guildFilter && guild.name !== guildFilter) continue;
const practicesDir = join(GUILDS_DIR, guild.name, "Practices");
try {
const practices = await readdir(practicesDir, { withFileTypes: true });
for (const practice of practices.filter((p) => p.isDirectory())) {
const overviewPath = join(practicesDir, practice.name, "README.md");
try {
const content = await readFile(overviewPath, "utf-8");
const purpose = extractPurposeSection(content);
if (purpose) {
results.push({ guild: guild.name, name: practice.name, purpose });
}
} catch {
// No overview — skip
}
}
} catch {
// No Practices directory — skip
}
}
return results;
}
/**
* Calls the GitHub Models API to perform MECE overlap analysis.
*
* @param {string} proposedName
* @param {string} proposedPurpose
* @param {Array<{ name: string, purpose: string }>} existing
* @param {"value-stream" | "practice"} type
* @param {string} token - GitHub token with models API access
* @returns {Promise<{ overlapRisk: string, concerns: string[], recommendation: string, comparedAgainst: string[] }>}
*/
export async function checkMECEOverlap(
proposedName,
proposedPurpose,
existing,
type,
token,
) {
const typeLabel =
type === "value-stream" ? "Value Stream" : "Management Practice";
const systemPrompt = `You are a MECE (Mutually Exclusive, Collectively Exhaustive) validation assistant for a company handbook. Your job is to determine if a proposed ${typeLabel} overlaps with any existing ${typeLabel}s.
Rules:
- MECE means each ${typeLabel} should have a distinct, non-overlapping scope
- Two ${typeLabel}s overlap if they could reasonably be merged or if work would be ambiguous about which one it belongs to
- Consider both explicit purpose statements and implied scope
- Be pragmatic — minor tangential connections are NOT overlaps
Respond in JSON format:
{
"overlapRisk": "low" | "medium" | "high",
"concerns": ["specific concern 1", "..."],
"recommendation": "brief recommendation"
}`;
const existingList = existing
.map((e) => `- **${e.name}:** ${e.purpose}`)
.join("\n");
const userPrompt = `## Proposed ${typeLabel}
**Name:** ${proposedName}
**Purpose:** ${proposedPurpose}
## Existing ${typeLabel}s
${existingList || "(None exist yet)"}
Analyse whether the proposed ${typeLabel} overlaps with any existing ones. Consider scope boundaries, customer overlap, and potential ambiguity about where work belongs.`;
try {
const response = await fetch(
"https://models.github.ai/inference/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openai/gpt-4o",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
temperature: 0.2,
response_format: { type: "json_object" },
}),
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`GitHub Models API error (${response.status}): ${errorText}`,
);
}
const data = await response.json();
const result = JSON.parse(data.choices[0].message.content);
return {
overlapRisk: result.overlapRisk || "unknown",
concerns: result.concerns || [],
recommendation: result.recommendation || "Unable to determine",
comparedAgainst: existing.map((e) => e.name),
};
} catch (error) {
// Graceful degradation — if AI check fails, return a warning instead of blocking
return {
overlapRisk: "unknown",
concerns: [`AI MECE check failed: ${error.message}`],
recommendation:
"Manual MECE review recommended — the AI-powered check was unable to complete.",
comparedAgainst: existing.map((e) => e.name),
};
}
}Environment Variable: The workflow will pass GITHUB_TOKEN (automatically available in Actions) or a dedicated GITHUB_MODELS_TOKEN secret if needed.
Verification: Test with a known-overlapping purpose (e.g., propose “Design to Deploy” and compare against “Discovery to Deployment”) and verify it flags the overlap.
Step 5: Create CODEOWNERS Management Script
Goal: Manage the .github/CODEOWNERS file — bootstrap it from the deferred plan if it doesn’t exist, append entries for new VS/Practices, and verify team existence via the GitHub API.
File: scripts/update-codeowners.mjs
// scripts/update-codeowners.mjs
import { readFile, writeFile, access } from "node:fs/promises";
import { resolve } from "node:path";
const CODEOWNERS_PATH = resolve(process.cwd(), ".github", "CODEOWNERS");
/**
* The initial CODEOWNERS content, bootstrapped from docs/CODEOWNERS_DEFERRED.md.
* Used only if the file doesn't exist yet.
*/
const INITIAL_CODEOWNERS = `# CODEOWNERS — Calab.ai Handbook
# Auto-managed by scaffolding automation. Manual edits are preserved.
# See: docs/CODEOWNERS_DEFERRED.md for governance context.
# Company Governance - Executive Guild only
/content/00 Governance/** @calab-ai/guild-executive
# Value Streams - General ownership
/content/01 Value Streams/** @calab-ai/owners-value-stream
# Value Stream - Specific overrides
# (New entries are appended here by the scaffolding automation)
# Guilds
/content/02 Guilds/GL01 Executive Guild/** @calab-ai/guild-executive
/content/02 Guilds/GL02 Sales Guild/** @calab-ai/guild-sales
/content/02 Guilds/GL03 Delivery Guild/** @calab-ai/guild-delivery
/content/02 Guilds/GL04 Technology Guild/** @calab-ai/guild-technology
/content/02 Guilds/GL05 Administration Guild/** @calab-ai/guild-administration
# Practice-specific overrides
# (New entries are appended here by the scaffolding automation)
# Products
/content/03 Products/** @calab-ai/owners-product
# Archive - Executive Guild only
/content/99 Archive/** @calab-ai/guild-executive
`;
/**
* Ensures the CODEOWNERS file exists. Creates it from the initial template if not.
* @returns {Promise<boolean>} - true if file was created, false if it already existed
*/
export async function ensureCodeownersExists() {
try {
await access(CODEOWNERS_PATH);
return false;
} catch {
await writeFile(CODEOWNERS_PATH, INITIAL_CODEOWNERS, "utf-8");
return true;
}
}
/**
* Appends a CODEOWNERS entry in the appropriate section.
* @param {string} contentPath - e.g., "/content/01 Value Streams/VS08 Plan to Innovate/**"
* @param {string} team - e.g., "@calab-ai/plan-to-innovate-team"
* @param {"value-stream" | "practice"} type
*/
export async function addCodeownersEntry(contentPath, team, type) {
await ensureCodeownersExists();
let content = await readFile(CODEOWNERS_PATH, "utf-8");
const entry = `${contentPath} ${team}`;
// Check if entry already exists
if (content.includes(contentPath)) {
return { added: false, reason: "Entry already exists" };
}
// Find the appropriate insertion point based on type
const sectionMarker =
type === "value-stream"
? "# Value Stream - Specific overrides"
: "# Practice-specific overrides";
const markerIndex = content.indexOf(sectionMarker);
if (markerIndex !== -1) {
// Find the end of the comment line after the marker
const afterMarker = content.indexOf("\n", markerIndex);
// Find the next non-comment, non-empty line or next section
let insertionPoint = afterMarker + 1;
const lines = content.slice(insertionPoint).split("\n");
let offset = 0;
for (const line of lines) {
if (line.startsWith("#") && !line.startsWith("# (")) break;
if (line.trim() === "") {
offset += line.length + 1;
continue;
}
if (line.startsWith("/content/")) {
offset += line.length + 1;
continue;
}
break;
}
insertionPoint += offset;
content =
content.slice(0, insertionPoint) +
entry +
"\n" +
content.slice(insertionPoint);
} else {
// Fallback: append to end
content += "\n" + entry + "\n";
}
await writeFile(CODEOWNERS_PATH, content, "utf-8");
return { added: true };
}
/**
* Verifies whether a GitHub team exists in the organisation.
* @param {string} org - e.g., "calab-ai"
* @param {string} teamSlug - e.g., "plan-to-innovate-team"
* @param {string} token - GitHub token
* @returns {Promise<{ exists: boolean, warning?: string }>}
*/
export async function verifyTeamExists(org, teamSlug, token) {
try {
const response = await fetch(
`https://api.github.com/orgs/${org}/teams/${teamSlug}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
},
);
if (response.status === 200) {
return { exists: true };
} else if (response.status === 404) {
return {
exists: false,
warning: `GitHub Team @${org}/${teamSlug} does not exist yet. The CODEOWNERS entry has been added but will not be functional until the team is created. See docs/CODEOWNERS_DEFERRED.md for the team creation process.`,
};
} else {
return {
exists: false,
warning: `Unable to verify team @${org}/${teamSlug} (API returned ${response.status}). Please verify manually.`,
};
}
} catch (error) {
return {
exists: false,
warning: `Unable to verify team @${org}/${teamSlug}: ${error.message}. Please verify manually.`,
};
}
}Verification: Run locally to verify CODEOWNERS creation and entry append:
node -e "
import { ensureCodeownersExists } from './scripts/update-codeowners.mjs';
const created = await ensureCodeownersExists();
console.log('Created:', created);
"Step 6: Create GitHub Issue Form Templates
Goal: Structured YAML issue forms that collect all required inputs for VS/Practice creation.
6A. Value Stream Issue Form
File: .github/ISSUE_TEMPLATE/create-value-stream.yml
name: "Create Value Stream"
description: "Request creation of a new Value Stream with automated scaffolding"
title: "[VS] "
labels: ["scaffolding", "value-stream"]
body:
- type: markdown
attributes:
value: |
## New Value Stream Request
Fill in the fields below to create a new Value Stream. Once submitted, automation will:
1. Validate naming, codes, and structure
2. Run a MECE overlap analysis against existing Value Streams
3. Scaffold the complete folder structure and files
4. Create a Pull Request with all generated content for your review
**All fields marked with * are required.**
- type: input
id: vs-name
attributes:
label: "Value Stream Name"
description: "The descriptive name (without VS code prefix). Use Title Case."
placeholder: "e.g., Plan to Innovate"
validations:
required: true
- type: input
id: vs-code
attributes:
label: "VS Code"
description: "A unique code in the format VS## (e.g., VS08). Must not already exist."
placeholder: "e.g., VS08"
validations:
required: true
- type: textarea
id: purpose
attributes:
label: "Purpose and Outcomes"
description: "2-3 sentences describing the end-to-end value this stream delivers."
placeholder: "This value stream represents..."
validations:
required: true
- type: input
id: primary-customer
attributes:
label: "Primary Customer"
description: "Who receives the value from this stream?"
placeholder: "e.g., External clients, Internal product teams"
validations:
required: true
- type: textarea
id: stakeholders
attributes:
label: "Key Stakeholders"
description: "List stakeholders and their interests (one per line)."
placeholder: |
- Sales Team - Pipeline visibility
- Finance - Revenue recognition
validations:
required: false
- type: input
id: start-trigger
attributes:
label: "Start Trigger"
description: "What event or condition initiates this value stream?"
placeholder: "e.g., New sales lead identified"
validations:
required: true
- type: input
id: end-trigger
attributes:
label: "End Trigger"
description: "What outcome or deliverable completes this value stream?"
placeholder: "e.g., Payment received and project closed"
validations:
required: true
- type: input
id: typical-duration
attributes:
label: "Typical Duration"
description: "How long from start to end under normal conditions?"
placeholder: "e.g., 2-6 months"
validations:
required: false
- type: input
id: vs-owner
attributes:
label: "Value Stream Owner"
description: "Name or role title of the person who owns this value stream."
placeholder: "e.g., Head of Innovation"
validations:
required: true
- type: input
id: team-slug
attributes:
label: "GitHub Team Slug"
description: "The GitHub team slug for CODEOWNERS (without @calab-ai/ prefix)."
placeholder: "e.g., plan-to-innovate-team"
validations:
required: true
- type: textarea
id: related-practices
attributes:
label: "Related Practices"
description: "Comma-separated list of existing management practices this VS coordinates."
placeholder: "e.g., Project Mgmt, Product Mgmt, Strategy Mgmt"
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: "Additional Context"
description: "Any extra information or context for reviewers."
validations:
required: false6B. Management Practice Issue Form
File: .github/ISSUE_TEMPLATE/create-management-practice.yml
name: "Create Management Practice"
description: "Request creation of a new Management Practice with automated scaffolding"
title: "[Practice] "
labels: ["scaffolding", "management-practice"]
body:
- type: markdown
attributes:
value: |
## New Management Practice Request
Fill in the fields below to create a new Management Practice. Once submitted, automation will:
1. Validate naming, Practice ID, and parent Guild
2. Run a MECE overlap analysis against existing practices
3. Scaffold the complete folder structure and files
4. Create a Pull Request with all generated content for your review
**All fields marked with * are required.**
- type: input
id: practice-name
attributes:
label: "Practice Name"
description: "Must end with 'Management' (e.g., 'Change Management'). Use Title Case."
placeholder: "e.g., Change Management"
validations:
required: true
- type: input
id: practice-id
attributes:
label: "Practice ID"
description: "Unique ID in format GUILD-ABBR-## (e.g., DEL-CHG-01). Check existing IDs to avoid collisions."
placeholder: "e.g., DEL-CHG-01"
validations:
required: true
- type: dropdown
id: parent-guild
attributes:
label: "Parent Guild"
description: "Which Guild will own this practice?"
options:
- "GL01 Executive Guild"
- "GL02 Sales Guild"
- "GL03 Delivery Guild"
- "GL04 Technology Guild"
- "GL05 Administration Guild"
validations:
required: true
- type: textarea
id: purpose
attributes:
label: "Purpose and Objectives"
description: "2-3 sentences describing what this practice aims to achieve and why it exists."
placeholder: "This practice aims to..."
validations:
required: true
- type: textarea
id: in-scope
attributes:
label: "In Scope"
description: "What is covered by this practice? One item per line."
placeholder: |
- Change request assessment and classification
- Impact analysis and approval workflows
validations:
required: true
- type: textarea
id: out-of-scope
attributes:
label: "Out of Scope"
description: "What is explicitly NOT covered? One item per line."
placeholder: |
- Incident management (covered by separate practice)
- Infrastructure provisioning
validations:
required: true
- type: input
id: owner-role
attributes:
label: "Practice Owner Role"
description: "The role title responsible for this practice."
placeholder: "e.g., Change Management Lead"
validations:
required: true
- type: input
id: team-slug
attributes:
label: "GitHub Team Slug (optional)"
description: "Practice-specific team for CODEOWNERS override (without @calab-ai/ prefix). Leave blank to use the Guild team."
placeholder: "e.g., change-mgmt-team"
validations:
required: false
- type: textarea
id: dependencies
attributes:
label: "Dependencies (Practices We Depend On)"
description: "Comma-separated list of practices this depends on."
placeholder: "e.g., Project Mgmt, Solution Eng"
validations:
required: false
- type: textarea
id: dependents
attributes:
label: "Dependents (Practices That Depend On Us)"
description: "Comma-separated list of practices that depend on this."
placeholder: "e.g., Software Eng"
validations:
required: false
- type: textarea
id: related-vs
attributes:
label: "Related Value Streams"
description: "Which Value Streams does this practice participate in?"
placeholder: "e.g., VS03 Request to Release, VS04 Incident to Resolution"
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: "Additional Context"
description: "Any extra information or context for reviewers."
validations:
required: falseVerification: Navigate to the repository’s Issues tab → “New Issue” and verify both forms render correctly with proper field types and validation.
Step 7: Create GitHub Actions Workflows
Goal: Automated workflows that trigger on issue creation, run validations, scaffold files, and create PRs.
7A. Scaffold Value Stream Workflow
File: .github/workflows/scaffold-value-stream.yml
name: "Scaffold Value Stream"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
pull-requests: write
models: read
jobs:
scaffold:
# Only run for issues with the 'value-stream' label
if: contains(github.event.issue.labels.*.name, 'value-stream')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install script dependencies
run: npm install gray-matter
- name: Parse issue and run validations
id: validate
uses: actions/github-script@v7
with:
script: |
const { parseIssueBody, parseList } = await import(
`${process.cwd()}/scripts/parse-issue.mjs`
);
const { validateValueStream } = await import(
`${process.cwd()}/scripts/validate-structure.mjs`
);
const body = context.payload.issue.body;
const fields = parseIssueBody(body);
// Map form fields to inputs
const inputs = {
name: fields["Value Stream Name"]?.trim(),
code: fields["VS Code"]?.trim().toUpperCase(),
purpose: fields["Purpose and Outcomes"]?.trim(),
primaryCustomer: fields["Primary Customer"]?.trim(),
stakeholders: fields["Key Stakeholders"]?.trim() || "",
startTrigger: fields["Start Trigger"]?.trim(),
endTrigger: fields["End Trigger"]?.trim(),
typicalDuration: fields["Typical Duration"]?.trim() || "",
owner: fields["Value Stream Owner"]?.trim(),
ownerSlug: (fields["Value Stream Owner"] || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-"),
teamSlug: fields["GitHub Team Slug"]?.trim(),
relatedPractices: parseList(fields["Related Practices"] || ""),
additionalContext: fields["Additional Context"]?.trim() || "",
author: context.payload.issue.user.login,
};
// Validate
const validation = await validateValueStream({
code: inputs.code,
name: inputs.name,
relatedPractices: inputs.relatedPractices,
});
core.setOutput("inputs", JSON.stringify(inputs));
core.setOutput("validation", JSON.stringify(validation));
core.setOutput("passed", validation.passed.toString());
- name: Run MECE analysis
id: mece
if: steps.validate.outputs.passed == 'true'
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
script: |
const { collectExistingVSPurposes, checkMECEOverlap } = await import(
`${process.cwd()}/scripts/mece-check.mjs`
);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
const existing = await collectExistingVSPurposes();
const meceResult = await checkMECEOverlap(
inputs.name,
inputs.purpose,
existing,
"value-stream",
process.env.GITHUB_TOKEN,
);
core.setOutput("mece", JSON.stringify(meceResult));
- name: Scaffold files
id: scaffold
if: steps.validate.outputs.passed == 'true'
uses: actions/github-script@v7
with:
script: |
const { scaffoldValueStream } = await import(
`${process.cwd()}/scripts/scaffold-files.mjs`
);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
const files = await scaffoldValueStream(inputs);
core.setOutput("files", JSON.stringify(files));
- name: Update CODEOWNERS
id: codeowners
if: steps.validate.outputs.passed == 'true'
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
script: |
const { addCodeownersEntry, verifyTeamExists } = await import(
`${process.cwd()}/scripts/update-codeowners.mjs`
);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
const contentPath = `/content/01 Value Streams/${inputs.code} ${inputs.name}/**`;
const team = `@calab-ai/${inputs.teamSlug}`;
const entryResult = await addCodeownersEntry(
contentPath,
team,
"value-stream",
);
const teamResult = await verifyTeamExists(
"calab-ai",
inputs.teamSlug,
process.env.GITHUB_TOKEN,
);
core.setOutput(
"codeowners",
JSON.stringify({ ...entryResult, ...teamResult }),
);
- name: Create branch and PR
if: steps.validate.outputs.passed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
INPUTS='${{ steps.validate.outputs.inputs }}'
VALIDATION='${{ steps.validate.outputs.validation }}'
MECE='${{ steps.mece.outputs.mece }}'
FILES='${{ steps.scaffold.outputs.files }}'
CODEOWNERS='${{ steps.codeowners.outputs.codeowners }}'
CODE=$(echo "$INPUTS" | jq -r '.code')
NAME=$(echo "$INPUTS" | jq -r '.name')
SLUG=$(echo "$NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
BRANCH="scaffolding/${CODE,,}-${SLUG}"
ISSUE_NUM=${{ github.event.issue.number }}
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Create branch and commit
git checkout -b "$BRANCH"
git add -A
git commit -m "Scaffold: ${CODE} ${NAME} (closes #${ISSUE_NUM})"
git push -u origin "$BRANCH"
# Build PR body
OVERLAP_RISK=$(echo "$MECE" | jq -r '.overlapRisk // "unknown"')
RECOMMENDATION=$(echo "$MECE" | jq -r '.recommendation // "N/A"')
CONCERNS=$(echo "$MECE" | jq -r '.concerns // [] | if length > 0 then map("- " + .) | join("\n") else "None identified" end')
COMPARED=$(echo "$MECE" | jq -r '.comparedAgainst // [] | if length > 0 then map("- " + .) | join("\n") else "None" end')
TEAM_WARNING=$(echo "$CODEOWNERS" | jq -r '.warning // "Team verified successfully"')
FILE_LIST=$(echo "$FILES" | jq -r '.[] | "- `" + . + "`"' | head -20)
# Format validation table
VALIDATION_TABLE=$(echo "$VALIDATION" | jq -r '.results[] | "| " + .check + " | " + (if .passed then "✅" else "❌" end) + " | " + .detail + " |"')
# Create PR
gh pr create \
--base main \
--head "$BRANCH" \
--title "[Scaffolding] ${CODE} ${NAME} — New Value Stream" \
--body "$(cat <<EOF
## Scaffolding Report
**Type:** Value Stream
**Source Issue:** #${ISSUE_NUM}
**Generated:** $(date -u +"%Y-%m-%d %H:%M UTC")
### Validation Results
| Check | Result | Details |
|-------|--------|---------|
${VALIDATION_TABLE}
### MECE Analysis
**Overlap Risk:** ${OVERLAP_RISK}
**Recommendation:** ${RECOMMENDATION}
${CONCERNS}
<details><summary>Compared Against</summary>
${COMPARED}
</details>
### CODEOWNERS
${TEAM_WARNING}
### Generated Files
${FILE_LIST}
### Next Steps
- [ ] Review generated content for accuracy
- [ ] Edit frontmatter fields (especially \`owner\` slug)
- [ ] Verify related practice cross-references
- [ ] Confirm CODEOWNERS entry is correct
- [ ] Update \`README.md\` with any additional detail
---
Closes #${ISSUE_NUM}
EOF
)"
- name: Comment on issue (failure)
if: steps.validate.outputs.passed == 'false'
uses: actions/github-script@v7
with:
script: |
const validation = JSON.parse('${{ steps.validate.outputs.validation }}');
const table = validation.results
.map(
(r) =>
`| ${r.check} | ${r.passed ? "✅" : "❌"} | ${r.detail} |`,
)
.join("\n");
const body = `## ❌ Scaffolding Validation Failed
The following checks did not pass. Please update the issue with corrected values.
| Check | Result | Details |
|-------|--------|---------|
${table}
Please close this issue and create a new one with corrected values, or edit this issue to fix the errors and re-open it.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});7B. Scaffold Management Practice Workflow
File: .github/workflows/scaffold-management-practice.yml
This workflow follows the identical pattern as 7A but uses practice-specific parsing, validation, scaffolding, and CODEOWNERS logic. The key differences:
- Parses practice-specific fields (Practice ID, Parent Guild, In/Out Scope, Dependencies, Dependents)
- Uses
validatePractice()instead ofvalidateValueStream() - Uses
collectExistingPracticePurposes()for MECE check (with same-guild primary check) - Uses
scaffoldPractice()instead ofscaffoldValueStream() - CODEOWNERS entry uses practice-specific path and optional team slug
name: "Scaffold Management Practice"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
pull-requests: write
models: read
jobs:
scaffold:
if: contains(github.event.issue.labels.*.name, 'management-practice')
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install script dependencies
run: npm install gray-matter
- name: Parse issue and run validations
id: validate
uses: actions/github-script@v7
with:
script: |
const { parseIssueBody, parseList, parseGuildSelection } = await import(
`${process.cwd()}/scripts/parse-issue.mjs`
);
const { validatePractice } = await import(
`${process.cwd()}/scripts/validate-structure.mjs`
);
const body = context.payload.issue.body;
const fields = parseIssueBody(body);
const guild = parseGuildSelection(fields["Parent Guild"]);
const inputs = {
name: fields["Practice Name"]?.trim(),
id: fields["Practice ID"]?.trim().toUpperCase(),
guildFolder: guild.folderName,
guildCode: guild.code,
purpose: fields["Purpose and Objectives"]?.trim(),
inScope: fields["In Scope"]?.trim() || "",
outOfScope: fields["Out of Scope"]?.trim() || "",
ownerRole: fields["Practice Owner Role"]?.trim(),
ownerSlug: (fields["Practice Owner Role"] || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-"),
teamSlug: fields["GitHub Team Slug (optional)"]?.trim() || "",
dependencies: parseList(
fields["Dependencies (Practices We Depend On)"] || "",
),
dependents: parseList(
fields["Dependents (Practices That Depend On Us)"] || "",
),
relatedVS: parseList(fields["Related Value Streams"] || ""),
additionalContext: fields["Additional Context"]?.trim() || "",
author: context.payload.issue.user.login,
};
const validation = await validatePractice({
id: inputs.id,
name: inputs.name,
guildFolder: inputs.guildFolder,
dependencies: inputs.dependencies,
dependents: inputs.dependents,
});
core.setOutput("inputs", JSON.stringify(inputs));
core.setOutput("validation", JSON.stringify(validation));
core.setOutput("passed", validation.passed.toString());
- name: Run MECE analysis
id: mece
if: steps.validate.outputs.passed == 'true'
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
script: |
const {
collectExistingPracticePurposes,
checkMECEOverlap,
} = await import(`${process.cwd()}/scripts/mece-check.mjs`);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
// Primary check: same guild
const sameGuild = await collectExistingPracticePurposes(
inputs.guildFolder,
);
// Secondary check: all guilds
const allPractices = await collectExistingPracticePurposes();
const meceResult = await checkMECEOverlap(
inputs.name,
inputs.purpose,
allPractices,
"practice",
process.env.GITHUB_TOKEN,
);
// Add same-guild specific note
const sameGuildNames = sameGuild.map((p) => p.name);
meceResult.sameGuildPractices = sameGuildNames;
core.setOutput("mece", JSON.stringify(meceResult));
- name: Scaffold files
id: scaffold
if: steps.validate.outputs.passed == 'true'
uses: actions/github-script@v7
with:
script: |
const { scaffoldPractice } = await import(
`${process.cwd()}/scripts/scaffold-files.mjs`
);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
const files = await scaffoldPractice(inputs);
core.setOutput("files", JSON.stringify(files));
- name: Update CODEOWNERS
id: codeowners
if: steps.validate.outputs.passed == 'true' && contains(steps.validate.outputs.inputs, '"teamSlug":')
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
script: |
const { addCodeownersEntry, verifyTeamExists } = await import(
`${process.cwd()}/scripts/update-codeowners.mjs`
);
const inputs = JSON.parse('${{ steps.validate.outputs.inputs }}');
// Only add practice-specific CODEOWNERS if a team slug was provided
if (!inputs.teamSlug) {
core.setOutput(
"codeowners",
JSON.stringify({
added: false,
reason: "No practice-specific team — using guild-level ownership",
exists: true,
}),
);
return;
}
const contentPath = `/content/02 Guilds/${inputs.guildFolder}/Practices/${inputs.name}/**`;
const team = `@calab-ai/${inputs.teamSlug}`;
const entryResult = await addCodeownersEntry(
contentPath,
team,
"practice",
);
const teamResult = await verifyTeamExists(
"calab-ai",
inputs.teamSlug,
process.env.GITHUB_TOKEN,
);
core.setOutput(
"codeowners",
JSON.stringify({ ...entryResult, ...teamResult }),
);
- name: Create branch and PR
if: steps.validate.outputs.passed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
INPUTS='${{ steps.validate.outputs.inputs }}'
VALIDATION='${{ steps.validate.outputs.validation }}'
MECE='${{ steps.mece.outputs.mece }}'
FILES='${{ steps.scaffold.outputs.files }}'
CODEOWNERS='${{ steps.codeowners.outputs.codeowners }}'
NAME=$(echo "$INPUTS" | jq -r '.name')
GUILD=$(echo "$INPUTS" | jq -r '.guildCode')
SLUG=$(echo "$NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
BRANCH="scaffolding/practice-${SLUG}"
ISSUE_NUM=${{ github.event.issue.number }}
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add -A
git commit -m "Scaffold: ${NAME} in ${GUILD} (closes #${ISSUE_NUM})"
git push -u origin "$BRANCH"
OVERLAP_RISK=$(echo "$MECE" | jq -r '.overlapRisk // "unknown"')
RECOMMENDATION=$(echo "$MECE" | jq -r '.recommendation // "N/A"')
CONCERNS=$(echo "$MECE" | jq -r '.concerns // [] | if length > 0 then map("- " + .) | join("\n") else "None identified" end')
COMPARED=$(echo "$MECE" | jq -r '.comparedAgainst // [] | if length > 0 then map("- " + .) | join("\n") else "None" end')
SAME_GUILD=$(echo "$MECE" | jq -r '.sameGuildPractices // [] | if length > 0 then map("- " + .) | join("\n") else "None" end')
TEAM_WARNING=$(echo "$CODEOWNERS" | jq -r '.warning // .reason // "OK"')
FILE_LIST=$(echo "$FILES" | jq -r '.[] | "- `" + . + "`"' | head -25)
VALIDATION_TABLE=$(echo "$VALIDATION" | jq -r '.results[] | "| " + .check + " | " + (if .passed then "✅" else "❌" end) + " | " + .detail + " |"')
gh pr create \
--base main \
--head "$BRANCH" \
--title "[Scaffolding] ${NAME} — New Management Practice" \
--body "$(cat <<EOF
## Scaffolding Report
**Type:** Management Practice
**Parent Guild:** $(echo "$INPUTS" | jq -r '.guildFolder')
**Source Issue:** #${ISSUE_NUM}
**Generated:** $(date -u +"%Y-%m-%d %H:%M UTC")
### Validation Results
| Check | Result | Details |
|-------|--------|---------|
${VALIDATION_TABLE}
### MECE Analysis
**Overlap Risk:** ${OVERLAP_RISK}
**Recommendation:** ${RECOMMENDATION}
${CONCERNS}
<details><summary>Practices in Same Guild</summary>
${SAME_GUILD}
</details>
<details><summary>All Practices Compared Against</summary>
${COMPARED}
</details>
### CODEOWNERS
${TEAM_WARNING}
### Generated Files
${FILE_LIST}
### Next Steps
- [ ] Review generated content for accuracy
- [ ] Edit frontmatter fields (especially \`owner\` slug)
- [ ] Verify dependency/dependent cross-references
- [ ] Confirm CODEOWNERS entry is correct (if applicable)
- [ ] Update \`README.md\` with additional detail
---
Closes #${ISSUE_NUM}
EOF
)"
- name: Comment on issue (failure)
if: steps.validate.outputs.passed == 'false'
uses: actions/github-script@v7
with:
script: |
const validation = JSON.parse('${{ steps.validate.outputs.validation }}');
const table = validation.results
.map(
(r) =>
`| ${r.check} | ${r.passed ? "✅" : "❌"} | ${r.detail} |`,
)
.join("\n");
const body = `## ❌ Scaffolding Validation Failed
The following checks did not pass. Please update the issue with corrected values.
| Check | Result | Details |
|-------|--------|---------|
${table}
Please close this issue and create a new one with corrected values, or edit this issue to fix the errors and re-open it.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});Verification: Create a test issue with the value-stream label and verify the workflow triggers, validates, scaffolds, and creates a PR. Check Actions tab for logs.
Step 8: Create Content Validation Workflow
Goal: A CI gate that validates content structure on all PRs touching content/**.
File: .github/workflows/validate-content.yml
name: "Validate Content"
on:
pull_request:
paths:
- "content/**"
- ".github/CODEOWNERS"
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm install gray-matter
- name: Validate frontmatter
id: frontmatter
uses: actions/github-script@v7
with:
script: |
const { validateAllContent } = await import(
`${process.cwd()}/scripts/validate-frontmatter.mjs`
);
const results = await validateAllContent("content");
const errors = results.filter((r) => !r.valid);
if (errors.length > 0) {
const errorList = errors
.map((e) => `- \`${e.file}\`: ${e.errors.join(", ")}`)
.join("\n");
core.setOutput("errors", errorList);
core.setOutput("passed", "false");
} else {
core.setOutput("passed", "true");
core.setOutput("errors", "");
}
- name: Check for duplicate codes
id: duplicates
uses: actions/github-script@v7
with:
script: |
const {
getExistingVSCodes,
getExistingPracticeIds,
} = await import(`${process.cwd()}/scripts/validate-structure.mjs`);
const vsCodes = await getExistingVSCodes();
const practiceIds = await getExistingPracticeIds();
const duplicateVS = vsCodes.filter(
(c, i) => vsCodes.indexOf(c) !== i,
);
const duplicatePractice = practiceIds.filter(
(p, i) => practiceIds.findIndex((q) => q.id === p.id) !== i,
);
const errors = [];
if (duplicateVS.length > 0) {
errors.push(`Duplicate VS codes: ${duplicateVS.join(", ")}`);
}
if (duplicatePractice.length > 0) {
errors.push(
`Duplicate Practice IDs: ${duplicatePractice.map((p) => p.id).join(", ")}`,
);
}
core.setOutput("passed", errors.length === 0 ? "true" : "false");
core.setOutput("errors", errors.join("\n"));
- name: Post validation results
if: always()
uses: actions/github-script@v7
with:
script: |
const fmPassed = "${{ steps.frontmatter.outputs.passed }}" === "true";
const fmErrors = `${{ steps.frontmatter.outputs.errors }}`;
const dupPassed = "${{ steps.duplicates.outputs.passed }}" === "true";
const dupErrors = `${{ steps.duplicates.outputs.errors }}`;
if (fmPassed && dupPassed) return; // All good, no comment needed
const body = `## Content Validation Results
${!fmPassed ? `### ❌ Frontmatter Issues\n\n${fmErrors}\n` : "### ✅ Frontmatter OK\n"}
${!dupPassed ? `### ❌ Duplicate Codes\n\n${dupErrors}\n` : "### ✅ No Duplicate Codes\n"}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
if (!fmPassed || !dupPassed) {
core.setFailed("Content validation failed");
}Supporting Script File: scripts/validate-frontmatter.mjs
// scripts/validate-frontmatter.mjs
import { readdir, readFile } from "node:fs/promises";
import { join, resolve, extname } from "node:path";
import matter from "gray-matter";
const CONTENT_DIR = resolve(process.cwd(), "content");
// Required frontmatter fields for all published content
const REQUIRED_FIELDS = ["page-status", "created", "owner", "type"];
// Additional required fields by type
const TYPE_REQUIRED_FIELDS = {
"value-stream-overview": ["vs-code"],
"practice-overview": ["practice-id", "guild"],
"role-criteria": ["guild", "role", "level", "criteria-type"],
};
// Directories to skip entirely
const SKIP_DIRS = [".obsidian", "metadata", "99 Archive"];
/**
* Validates frontmatter for a single markdown file.
* @param {string} filePath - Absolute path to the file
* @returns {{ valid: boolean, errors: string[], file: string }}
*/
export async function validateFrontmatter(filePath) {
const errors = [];
const relativePath = filePath.replace(CONTENT_DIR + "/", "");
try {
const content = await readFile(filePath, "utf-8");
const { data } = matter(content);
// Check if file has any frontmatter
if (Object.keys(data).length === 0) {
errors.push("Missing frontmatter");
return { valid: false, errors, file: relativePath };
}
// Check required fields
for (const field of REQUIRED_FIELDS) {
if (
data[field] === undefined ||
data[field] === null ||
data[field] === ""
) {
errors.push(`Missing required field: ${field}`);
}
}
// Check type-specific fields
const type = data.type;
if (type && TYPE_REQUIRED_FIELDS[type]) {
for (const field of TYPE_REQUIRED_FIELDS[type]) {
if (
data[field] === undefined ||
data[field] === null ||
data[field] === ""
) {
errors.push(`Missing required field for type "${type}": ${field}`);
}
}
}
// Validate page-status values
const validStatuses = ["draft", "review", "active", "deprecated"];
if (data["page-status"]) {
const status = Array.isArray(data["page-status"])
? data["page-status"][0]
: data["page-status"];
if (!validStatuses.includes(status)) {
errors.push(
`Invalid page-status "${status}". Must be one of: ${validStatuses.join(", ")}`,
);
}
}
// Validate created date format
if (data.created && !/^\d{4}-\d{2}-\d{2}$/.test(String(data.created))) {
errors.push(
`Invalid created date format "${data.created}". Must be YYYY-MM-DD`,
);
}
} catch (error) {
errors.push(`Error reading file: ${error.message}`);
}
return { valid: errors.length === 0, errors, file: relativePath };
}
/**
* Recursively finds all markdown files in a directory, skipping excluded dirs.
*/
async function findMarkdownFiles(dir) {
const files = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (
SKIP_DIRS.some(
(skip) => entry.name === skip || entry.name.startsWith("_"),
)
) {
continue;
}
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await findMarkdownFiles(fullPath)));
} else if (extname(entry.name) === ".md") {
files.push(fullPath);
}
}
return files;
}
/**
* Validates frontmatter for all markdown files in the content directory.
* @param {string} [contentDirName="content"] - Content directory name relative to cwd
* @returns {Promise<Array<{ valid: boolean, errors: string[], file: string }>>}
*/
export async function validateAllContent(contentDirName = "content") {
const contentDir = resolve(process.cwd(), contentDirName);
const files = await findMarkdownFiles(contentDir);
const results = [];
for (const file of files) {
const result = await validateFrontmatter(file);
results.push(result);
}
return results;
}Verification: Run locally:
node -e "
import { validateAllContent } from './scripts/validate-frontmatter.mjs';
const results = await validateAllContent('content');
const errors = results.filter(r => !r.valid);
console.log('Files with issues:', errors.length);
errors.forEach(e => console.log(e.file, e.errors));
"Step 9: Create PR Template
Goal: A general-purpose PR template for all PRs, with scaffolding-specific sections.
File: .github/pull_request_template.md
## Description
<!-- What does this PR do? Why is it needed? -->
## Type of Change
- [ ] Content (documentation, handbook content)
- [ ] Scaffolding (new Value Stream or Management Practice)
- [ ] Tooling (CI/CD, scripts, configuration)
- [ ] Bug fix
## Scaffolding Checklist (if applicable)
- [ ] Folder structure follows Knowledge Standards
- [ ] All frontmatter fields populated
- [ ] MECE validation passed (no overlap concerns)
- [ ] CODEOWNERS entry added (if applicable)
- [ ] Related cross-references are accurateVerification: Create a PR manually and verify the template pre-populates the PR body.
Step 10: Template Cleanup and Documentation Updates
Goal: Remove superseded Obsidian Templater templates and update all documentation references to maintain MECE integrity (no duplication, no stale references).
10A. Delete superseded templates
rm "content/metadata/templater/TMP01 Value Stream Overview.md"
rm "content/metadata/templater/TMP03 Practice Overview.md"10B. Update content/00 Governance/01 How to Contribute.md
Changes:
- Replace the template reference at line 36 (
content/_templater/) with instructions for the new scaffolding workflow - Distinguish between VS/Practice creation (use Issue Form) and content within practices (manual)
- Fix the stale
_templaterpath reference
Updated section (replace lines 26-56):
### 1. Identify Where Your Content Belongs
Use the content classification decision tree in [[Content Types|Content Types]] to determine:
- What type of content you're creating (Policy, Process, Procedure, Guide, Template, or Decision)
- Which Practice owns this type of content
- Where the file should be placed in the folder structure
### 2. Create Your Content
#### Creating a New Value Stream or Management Practice
New Value Streams and Management Practices are created through automated scaffolding:
1. Go to the [repository Issues tab](https://github.com/calab-ai/calab-handbook/issues/new/choose)
2. Select **"Create Value Stream"** or **"Create Management Practice"**
3. Fill in all required fields in the issue form
4. Submit the issue — automation will validate inputs, run MECE analysis, and create a Pull Request with the complete folder structure and populated files
5. Review the generated PR and make any needed edits
#### Creating Content Within an Existing Practice
For policies, processes, procedures, guides, templates, and decisions within an existing practice:
1. **Use the appropriate template** from `content/metadata/templater/` (available for decisions, Guilds, and Products)
2. **Follow naming conventions:**
- Use descriptive names: `Incident Response Process.md`
- Include version numbers if needed: `Security Baseline Policy v2.0.md`
3. **Add proper frontmatter:**
```yaml
---
page-status: draft
created: YYYY-MM-DD
owner: [guild-slug]
type: [policy|process|procedure|guide|template|decision]
---
```3. Submit for Review
- Create a feature branch:
git checkout -b feat/[description] - Add your changes:
git add . - Commit with clear message:
git commit -m "Add: [description]" - Push to remote:
git push -u origin feat/[description] - Create a Pull Request
4. Review and Approval
- Content validation runs automatically on all PRs touching
content/ - GitHub CODEOWNERS automatically assigns reviewers based on the file path
- Address any feedback from reviewers
- Once approved, your PR will be merged
#### 10C. Update `content/00 Governance/04 Decision Records.md`
**Change line 62:** Replace `content/_templater/TMP01 Create ADR.md` with `content/metadata/templater/TMP00 Create Decision.md` (pre-existing bug fix).
#### 10D. Update `content/02 Guilds/GL04 Technology Guild/Practices/Solution Eng/README.md`
**Change line 66:** Replace `ADR Template (TMP01)` with `Decision Template (TMP00)` (pre-existing bug fix).
#### 10E. Update `docs/REPOSITORY_STRUCTURE.md`
Add the new `scripts/` directory to the structure table:
```markdown
| `scripts/` | Automation scripts (validation, scaffolding) | N/A (not published) |
Update the templater row:
| `content/metadata/templater/` | Obsidian note templates (Decision, Guild, Product) | No (ignored) |10F. Update docs/CODEOWNERS_DEFERRED.md
Add a note at the top of the Status section:
## Status
**PARTIALLY ADDRESSED** — The scaffolding automation (Plan 08) now manages CODEOWNERS entries automatically when new Value Streams or Management Practices are created. The full CODEOWNERS implementation (including GitHub Teams creation) remains deferred.
See: `content/metadata/plans/08 Automated Content Scaffolding/README.md`Verification: Search for all remaining references to TMP01, TMP03, and _templater across the repository. The only surviving references should be in historical content/metadata/plans/ files (which are immutable records of past plans) and this plan itself.
grep -r "TMP01\|TMP03\|_templater" content/ --include="*.md" -l
# Expected: No results (all references cleaned up from content/)Step 11: Create Decision for This Automation
Goal: Document the architectural decision to adopt automated content scaffolding.
File: content/metadata/decisions/06 Automated Content Scaffolding.md
# Decision 06: Automated Content Scaffolding via GitHub Issue Forms
## Status
Accepted
## Context
Creating new Value Streams and Management Practices requires:
- Creating a specific folder structure with 6-10 files
- Populating frontmatter with correct metadata fields
- Following naming conventions (Title Case, "Management" suffix, VS code format)
- Ensuring MECE compliance (no overlap with existing VS/Practices)
- Updating CODEOWNERS entries
- Cross-referencing related practices
This process was entirely manual, relying on Obsidian Templater templates (TMP01, TMP03) and human diligence. This was error-prone (inconsistent frontmatter, missing folders, naming violations) and created friction for contributors.
## Decision
Implement a GitHub-native automation system:
1. **GitHub Issue Forms** as the structured input mechanism
2. **GitHub Actions workflows** triggered on issue creation to validate, scaffold, and create PRs
3. **Node.js ESM scripts** for validation, MECE checking, file generation, and CODEOWNERS management
4. **GitHub Models API** for AI-powered semantic overlap detection
5. **Content validation CI workflow** as a quality gate on all content PRs
The scaffolding scripts own the template logic (inline template strings), superseding the Obsidian Templater files TMP01 and TMP03.
## Consequences
### Positive
- Zero manual scaffolding — complete folder structure generated from structured inputs
- Governance enforcement via deterministic validation
- MECE integrity via AI-assisted semantic analysis
- Audit trail from issue → PR → merge
- CODEOWNERS proactively managed
### Negative
- Templates for VS/Practice creation are now in JavaScript, not easily editable Obsidian markdown
- Requires GitHub Actions minutes (minimal — runs only on issue creation)
- AI MECE check adds dependency on GitHub Models API (graceful degradation if unavailable)
### Neutral
- Obsidian Templater templates TMP00 (Decision), TMP02 (Guild), TMP05 (Product) remain unchanged
- Obsidian Templater plugin continues to work for remaining template types
- Historical plan documents retain references to TMP01/TMP03 as immutable records
## Alternatives Considered
### 1. Enhanced Obsidian Templater Scripts
Use Templater's JavaScript execution capabilities to add validation and folder creation.
- **Pros:** Stays within Obsidian ecosystem; familiar to content authors
- **Cons:** No CI enforcement; no MECE checking; no audit trail; only works locally; no CODEOWNERS management
- **Why not chosen:** Doesn't address governance requirements
### 2. CLI Script (npm run create:vs)
Local Node.js script with interactive prompts.
- **Pros:** Fast local feedback; no GitHub dependency
- **Cons:** No audit trail; no MECE checking; requires local Node.js setup; bypasses governance
- **Why not chosen:** Doesn't create the issue → PR → review governance chain
### 3. GitHub Actions with Manual Trigger (workflow_dispatch)
Workflow triggered manually after issue approval rather than on issue creation.
- **Pros:** Adds an explicit approval step before scaffolding
- **Cons:** Extra manual step; the PR itself already serves as the review/approval gate
- **Why not chosen:** The PR review process provides sufficient governance control
## Related Decisions
- Plan 01: Governance Model & Content Structure (established the folder patterns)
- CODEOWNERS Deferred (established team structure expectations)
- Plan 06: Site UX Improvements (established page-list auto-population)
---
**Date:** 2026-02-17
**Decided By:** Leadership Team
**Approved By:** [Pending]Verification: Review decision for completeness and accuracy against the decision template structure in content/00 Governance/04 Decision Records.md.
4. Decision Points & Options
Decision 1: GitHub Models API Availability
The GitHub Models API is used for MECE checking. If it’s not available for this repository:
| Option | Pros | Cons | Recommendation |
|---|---|---|---|
| A. GitHub Models API | Free, integrated, no secrets needed | Requires API access enabled | Recommended |
| B. Azure OpenAI | Calab.ai likely has Azure (Entra ID) | Requires Azure resource + secret | Fallback option |
| C. Skip AI, deterministic only | No external dependency | No semantic overlap detection | Minimal viable option |
Recommendation: Start with Option A. The mece-check.mjs script has graceful degradation — if the API call fails, it returns a “manual review recommended” result instead of blocking.
Decision 2: gray-matter Dependency
The validation scripts use gray-matter for YAML frontmatter parsing. It may already be in the Quartz dependency tree.
npm ls gray-matter| Scenario | Action |
|---|---|
| Already installed (transitive dep) | No action needed — import directly |
| Not installed | Add to workflow: npm install gray-matter in the Actions step (not local install) |
| Want to avoid new dependency | Replace with manual YAML parsing (regex-based, more fragile) |
Recommendation: Use gray-matter and install it in the Actions workflow step. It’s the standard Node.js frontmatter parser and is lightweight.
5. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| GitHub Models API not available for repo | Low | Medium | Graceful degradation in mece-check.mjs; returns advisory warning instead of blocking |
| AI MECE false positives | Medium | Low | Advisory only — included in PR report with “compared against” list for human verification |
| Issue form fields insufficient for edge cases | Medium | Low | ”Additional Context” textarea covers gaps; forms are extensible via YAML changes |
| CODEOWNERS teams don’t exist yet | High (known) | Low | Warning in PR body; entry scaffolded but non-functional until teams are created |
| Workflow permissions issues | Low | Medium | Documented required permissions in workflow YAML; uses standard GITHUB_TOKEN |
gray-matter import issues in ESM | Low | Low | Install fresh in Actions step; tested in CI environment |
| Race condition: two scaffolding issues at once | Very Low | Medium | Branch names include unique VS code/practice slug; unlikely collision |
| Template format drift from Knowledge Standards | Low | Medium | Scripts are the source of truth; documentation cross-references the scripts |
npm install gray-matter in Actions | Very Low | Low | Actions runs on Ubuntu (not WSL/NTFS); npm install is safe there |
6. Success Criteria
Functional Criteria
-
Creating a GitHub issue with the “Create Value Stream” form and correct inputs results in a PR with:
- Complete folder structure (6 markdown files + metadata subdirs)
- Correctly populated frontmatter in all files
- CODEOWNERS entry appended
- Validation report in PR body
- MECE analysis in PR body
-
Creating a GitHub issue with the “Create Management Practice” form and correct inputs results in a PR with:
- Complete folder structure (overview + 7 artefact subdirs + metadata)
- Correctly populated frontmatter
- CODEOWNERS entry (if team provided)
- Validation report in PR body
- MECE analysis in PR body
-
Creating an issue with invalid inputs (duplicate VS code, bad practice ID format, non-existent guild) results in:
- A comment on the issue listing all validation failures
- No PR created
-
The
validate-content.ymlworkflow catches:- Missing required frontmatter fields
- Duplicate VS codes or Practice IDs
- And reports results as a PR comment
Non-Functional Criteria
- Scaffolding workflow completes in under 2 minutes
- All documentation references to TMP01 and TMP03 are removed from
content/directory - Existing Obsidian Templater workflow continues to work for TMP00, TMP02, TMP05
- No breaking changes to the Quartz build (
npx quartz buildsucceeds)
7. Appendices
Appendix A: Complete File Inventory
New Files (17)
| File | Purpose |
|---|---|
.github/ISSUE_TEMPLATE/create-value-stream.yml | Issue form for VS creation |
.github/ISSUE_TEMPLATE/create-management-practice.yml | Issue form for Practice creation |
.github/pull_request_template.md | General PR template |
.github/CODEOWNERS | Bootstrapped CODEOWNERS file |
.github/workflows/scaffold-value-stream.yml | VS scaffolding workflow |
.github/workflows/scaffold-management-practice.yml | Practice scaffolding workflow |
.github/workflows/validate-content.yml | Content validation CI gate |
scripts/parse-issue.mjs | Issue body parser utility |
scripts/validate-structure.mjs | Deterministic structural validation |
scripts/scaffold-files.mjs | File generation (owns VS/Practice templates) |
scripts/mece-check.mjs | AI-powered MECE overlap detection |
scripts/update-codeowners.mjs | CODEOWNERS file management |
scripts/validate-frontmatter.mjs | Frontmatter validation for CI |
content/metadata/decisions/06 Automated Content Scaffolding.md | Decision record |
content/metadata/plans/08 Automated Content Scaffolding/README.md | This plan |
Deleted Files (2)
| File | Reason |
|---|---|
content/metadata/templater/TMP01 Value Stream Overview.md | Superseded by scripts/scaffold-files.mjs |
content/metadata/templater/TMP03 Practice Overview.md | Superseded by scripts/scaffold-files.mjs |
Modified Files (6)
| File | Changes |
|---|---|
content/00 Governance/01 How to Contribute.md | Replace template reference with scaffolding workflow instructions; fix _templater path |
content/00 Governance/04 Decision Records.md | Fix TMP01 → TMP00 reference (line 62) |
content/02 Guilds/GL04 Technology Guild/Practices/Solution Eng/README.md | Fix TMP01 → TMP00 reference (line 66) |
content/00 Governance/03 Knowledge Standards.md | Add scaffolding workflow note under Structural Standards |
docs/REPOSITORY_STRUCTURE.md | Add scripts/ directory; update templater description |
docs/CODEOWNERS_DEFERRED.md | Update status to reflect automation |
Appendix B: Template Comparison
The following table shows the mapping from the old Obsidian Templater template fields to the new scaffolding script inputs:
TMP01 Value Stream Overview → scaffold-files.mjs vsOverviewTemplate()
| Old Template Field | New Script Input |
|---|---|
<% tp.date.now("YYYY-MM-DD") %> | new Date().toISOString().slice(0, 10) |
[value-stream-owner] | inputs.ownerSlug |
[VS0X] | inputs.code |
[VS Code] [Value Stream Name] | ${inputs.code} ${inputs.name} |
[2-3 sentences...] (Purpose) | inputs.purpose |
[Who receives the value] | inputs.primaryCustomer |
[What initiates...] (Start Trigger) | inputs.startTrigger |
[What completes...] (End Trigger) | inputs.endTrigger |
[Name/Role] (Owner) | inputs.owner |
@calab-ai/[vs-team-name] | @calab-ai/${inputs.teamSlug} |
[[Practice N]] (Related Practices) | inputs.relatedPractices.map(p => \${p}`)` |
TMP03 Practice Overview → scaffold-files.mjs practiceOverviewTemplate()
| Old Template Field | New Script Input |
|---|---|
<% tp.date.now("YYYY-MM-DD") %> | new Date().toISOString().slice(0, 10) |
[practice-owner-role] | inputs.ownerSlug |
[PRACTICE-ID] | inputs.id |
[Guild Name] | inputs.guildFolder |
[Practice Name] | inputs.name |
[Role responsible] | inputs.ownerRole |
[2-3 sentences...] (Purpose) | inputs.purpose |
| In/Out of Scope items | inputs.inScope / inputs.outOfScope |
[[Practice Name N]] (Dependencies) | inputs.dependencies.map(...) |
[[Practice Name N]] (Dependents) | inputs.dependents.map(...) |
Appendix C: Existing Content That Auto-Updates
The following content does not need manual updating when a new VS or Practice is scaffolded, because it uses page-list auto-populated listings:
| File | Mechanism |
|---|---|
content/01 Value Streams/README.md | page-list code block scans 01 Value Streams/ |
content/02 Guilds/README.md | page-list code block scans 02 Guilds/ |
Each Guild’s Practices/README.md | page-list code block scans the guild’s Practices/ directory |
No cross-reference updates are needed in these files — the page-list plugin automatically picks up new folders at build time.
Appendix D: Troubleshooting
| Issue | Cause | Fix |
|---|---|---|
| Workflow doesn’t trigger | Issue missing required label | Ensure issue form labels field includes value-stream or management-practice |
gray-matter import fails | Module not installed in Actions | Verify npm install gray-matter step runs before validation step |
| MECE check returns “unknown” | GitHub Models API not accessible | Check repo permissions; fallback is manual review (non-blocking) |
| CODEOWNERS entry not added | File has unexpected format | Check CODEOWNERS section markers; run update-codeowners.mjs locally to debug |
| PR creation fails | Branch name collision | Delete stale scaffolding branches; check for existing PR |
| Frontmatter validation false positives | Stub files without full frontmatter | Ensure stub templates include minimum required fields |
npm install in WSL corrupts packages | WSL + NTFS filesystem boundary | Never run npm install from WSL. The Actions workflow runs on Ubuntu (not WSL), so this only affects local development. See .opencode/skills/npm-usage/SKILL.md. |