@benjaminthomas/spec-driven-dev 0.0.0-stage → 1.4.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/LICENSE +21 -0
- package/README.md +483 -2
- package/bin/cli.mjs +357 -0
- package/package.json +12 -4
- package/skills/generative-seo/SKILL.md +255 -0
- package/skills/generative-seo/assets/citation-log-template.md +17 -0
- package/skills/generative-seo/assets/competitor-analysis-template.md +20 -0
- package/skills/generative-seo/assets/content-plan-template.md +15 -0
- package/skills/generative-seo/assets/geo-config-template.md +33 -0
- package/skills/generative-seo/assets/geo-playbook-template.md +22 -0
- package/skills/generative-seo/assets/social-queue-template.md +8 -0
- package/skills/generative-seo/references/geo-methodology.md +143 -0
- package/skills/spec-checkpoint/SKILL.md +106 -0
- package/skills/spec-create/SKILL.md +149 -0
- package/skills/spec-create/references/action-required-template.md +53 -0
- package/skills/spec-create/references/readme-template.md +53 -0
- package/skills/spec-create/references/requirements-template.md +54 -0
- package/skills/spec-create/references/task-template.md +86 -0
- package/skills/spec-implement/SKILL.md +236 -0
- package/skills/spec-implement/references/coder-prompt-template.md +48 -0
- package/skills/spec-implement/references/fix-prompt-template.md +38 -0
- package/skills/spec-implement/references/review-prompt-template.md +50 -0
- package/skills/spec-investigate/SKILL.md +323 -0
- package/skills/spec-investigate/references/report-template.md +53 -0
- package/skills/spec-setup/SKILL.md +99 -0
- package/skills/spec-setup/references/config-template.yml +45 -0
- package/skills/spec-ship/SKILL.md +218 -0
- package/skills/spec-verify/SKILL.md +153 -0
- package/skills/spec-verify/references/report-template.md +57 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// spec-driven-dev installer: detects your AI coding tools, copies the skills into each one, and
|
|
3
|
+
// runs project discovery (layout, tracker, branches, commands) to write .spec-driven.yml.
|
|
4
|
+
//
|
|
5
|
+
// npx @benjaminthomas/spec-driven-dev install + project setup (interactive)
|
|
6
|
+
// npx @benjaminthomas/spec-driven-dev setup project setup only
|
|
7
|
+
// npx @benjaminthomas/spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
|
|
8
|
+
// npx @benjaminthomas/spec-driven-dev status list specs with unfinished tasks (used by the session-start hook)
|
|
9
|
+
// flags: --yes --global --tools=claude,cursor --skills=spec-create,... --no-setup --dir=<path>
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
const SKILLS_DIR = path.join(ROOT, "skills");
|
|
19
|
+
const HOME = process.env.SPEC_DRIVEN_HOME || os.homedir(); // overridable for tests
|
|
20
|
+
const ALL_SKILLS = fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, "SKILL.md")));
|
|
21
|
+
|
|
22
|
+
const TOOLS = {
|
|
23
|
+
claude: { label: "Claude Code", home: ".claude", bin: "claude", project: ".claude/skills", global: ".claude/skills" },
|
|
24
|
+
cursor: { label: "Cursor", home: ".cursor", bin: "cursor", project: ".cursor/skills", global: ".cursor/skills" },
|
|
25
|
+
codex: { label: "Codex CLI", home: ".codex", bin: "codex", project: ".agents/skills", global: ".agents/skills" }, // Codex reads $HOME/.agents/skills
|
|
26
|
+
antigravity: { label: "Antigravity", home: ".gemini", bin: "antigravity", project: ".agents/skills", global: ".gemini/config/skills" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ---------- args & prompts ----------
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const flag = (n) => args.includes(`--${n}`);
|
|
32
|
+
const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
|
|
33
|
+
const command = args.find((a) => !a.startsWith("-")) || "install";
|
|
34
|
+
const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status"].includes(command);
|
|
35
|
+
const DIR = path.resolve(opt("dir") || process.cwd());
|
|
36
|
+
|
|
37
|
+
if (flag("help") || flag("h")) {
|
|
38
|
+
console.log(fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 9).join("\n").replace(/^\/\/ ?/gm, ""));
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rl = YES ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
async function ask(q, def = "") {
|
|
44
|
+
if (YES) return def;
|
|
45
|
+
const a = (await rl.question(`${q}${def ? ` [${def}]` : ""}: `)).trim();
|
|
46
|
+
return a || def;
|
|
47
|
+
}
|
|
48
|
+
async function pick(q, items, defaults) {
|
|
49
|
+
items.forEach((it, i) => console.log(` ${i + 1}. ${it.label}`));
|
|
50
|
+
const defIdx = items.map((it, i) => (defaults.includes(it.value) ? i + 1 : null)).filter(Boolean).join(",");
|
|
51
|
+
const a = await ask(`${q} (comma-separated numbers, "all", or "none")`, defIdx || "none");
|
|
52
|
+
if (a === "all") return items.map((it) => it.value);
|
|
53
|
+
if (a === "none") return [];
|
|
54
|
+
return a.split(",").map((n) => items[Number(n.trim()) - 1]?.value).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const run = (cmd, argv, cwd) => {
|
|
58
|
+
try { return execFileSync(cmd, argv, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
|
|
59
|
+
};
|
|
60
|
+
const onPath = (bin) => !!run(process.platform === "win32" ? "where" : "which", [bin]);
|
|
61
|
+
|
|
62
|
+
// ---------- install ----------
|
|
63
|
+
async function install() {
|
|
64
|
+
const detected = Object.keys(TOOLS).filter((t) => fs.existsSync(path.join(HOME, TOOLS[t].home)) || onPath(TOOLS[t].bin));
|
|
65
|
+
let tools = opt("tools")?.split(",");
|
|
66
|
+
if (!tools) {
|
|
67
|
+
console.log("\nWhich AI coding tools should get the skills? (detected ones are pre-selected)");
|
|
68
|
+
tools = await pick("Tools", Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label + (detected.includes(value) ? " ✓ detected" : "") })), detected);
|
|
69
|
+
}
|
|
70
|
+
const bad = tools.filter((t) => !TOOLS[t]);
|
|
71
|
+
if (bad.length) throw new Error(`Unknown tool(s): ${bad.join(", ")} — expected ${Object.keys(TOOLS).join(", ")}`);
|
|
72
|
+
|
|
73
|
+
// Claude Code users who already have the plugin would get every skill twice.
|
|
74
|
+
const plugins = path.join(HOME, ".claude/plugins/installed_plugins.json");
|
|
75
|
+
if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && fs.readFileSync(plugins, "utf8").includes('"spec-driven-dev@')) {
|
|
76
|
+
console.log("• Claude Code already has the spec-driven-dev plugin, so it's skipped here (update it with /plugin).");
|
|
77
|
+
tools = tools.filter((t) => t !== "claude");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const global = flag("global") || (!YES && (await ask("Install for this project only, or globally for all projects? (project/global)", "project")) === "global");
|
|
81
|
+
const skills = opt("skills")?.split(",") || ALL_SKILLS;
|
|
82
|
+
const unknown = skills.filter((s) => !ALL_SKILLS.includes(s));
|
|
83
|
+
if (unknown.length) throw new Error(`Unknown skill(s): ${unknown.join(", ")} — expected ${ALL_SKILLS.join(", ")}`);
|
|
84
|
+
|
|
85
|
+
const dests = [...new Set(tools.map((t) => (global ? path.join(HOME, TOOLS[t].global) : path.join(DIR, TOOLS[t].project))))];
|
|
86
|
+
for (const dest of dests) {
|
|
87
|
+
for (const s of skills) {
|
|
88
|
+
const to = path.join(dest, s);
|
|
89
|
+
fs.rmSync(to, { recursive: true, force: true });
|
|
90
|
+
fs.cpSync(path.join(SKILLS_DIR, s), to, { recursive: true, dereference: true, filter: (f) => !f.endsWith(".DS_Store") });
|
|
91
|
+
}
|
|
92
|
+
console.log(`✓ ${skills.length} skills → ${dest}`);
|
|
93
|
+
}
|
|
94
|
+
if (!dests.length) console.log("• No tools selected — nothing installed.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- discovery ----------
|
|
98
|
+
const MARKERS = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "go.work"];
|
|
99
|
+
const PKG_DIRS = ["apps", "packages", "services", "libs"];
|
|
100
|
+
const MANIFESTS = ["package.json", "go.mod", "pyproject.toml", "Cargo.toml", "composer.json", "pom.xml"];
|
|
101
|
+
const isRepo = (d) => run("git", ["rev-parse", "--is-inside-work-tree"], d) === "true";
|
|
102
|
+
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
|
|
103
|
+
const subdirs = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
|
|
104
|
+
|
|
105
|
+
function detectLayout(dir) {
|
|
106
|
+
if (isRepo(dir)) {
|
|
107
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], dir);
|
|
108
|
+
const pkg = readJson(path.join(top, "package.json"));
|
|
109
|
+
const cargo = fs.existsSync(path.join(top, "Cargo.toml")) && fs.readFileSync(path.join(top, "Cargo.toml"), "utf8").includes("[workspace]");
|
|
110
|
+
const hasManifest = (rel) => MANIFESTS.some((m) => fs.existsSync(path.join(top, rel, m)));
|
|
111
|
+
// Prefer the workspace list the repo declares; fall back to conventional folders.
|
|
112
|
+
// shortcut: only "dir/*", "dir/**" and exact paths are expanded — enough for typical workspace globs.
|
|
113
|
+
const pnpm = fs.existsSync(path.join(top, "pnpm-workspace.yaml")) ? [...fs.readFileSync(path.join(top, "pnpm-workspace.yaml"), "utf8").matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)].map((m) => m[1]) : [];
|
|
114
|
+
const declared = [...(Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages || []), ...pnpm].filter((g) => !g.startsWith("!"));
|
|
115
|
+
const globs = declared.length ? declared : PKG_DIRS.map((p) => `${p}/*`);
|
|
116
|
+
const packages = [...new Set(globs.flatMap((g) => {
|
|
117
|
+
const base = g.replace(/\/\*\*?$/, "");
|
|
118
|
+
return base === g ? [g.replace(/\/$/, "")] : subdirs(path.join(top, base)).map((n) => `${base}/${n}`);
|
|
119
|
+
}))].filter(hasManifest);
|
|
120
|
+
const mono = MARKERS.some((m) => fs.existsSync(path.join(top, m))) || pkg?.workspaces || cargo || packages.length > 1;
|
|
121
|
+
return { layout: mono ? "monorepo" : "single", root: top, repos: mono ? packages : [] };
|
|
122
|
+
}
|
|
123
|
+
// a git worktree has a .git *file*; it belongs to its main repo, so skip it
|
|
124
|
+
const repos = subdirs(dir).filter((n) => { try { return fs.statSync(path.join(dir, n, ".git")).isDirectory(); } catch { return false; } });
|
|
125
|
+
return repos.length ? { layout: "multi-repo", root: dir, repos } : { layout: "single", root: dir, repos: [] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function commandsFor(dir) {
|
|
129
|
+
const pkg = readJson(path.join(dir, "package.json"));
|
|
130
|
+
const out = { install: "", lint: "", typecheck: "", test: "", build: "", dev: "" };
|
|
131
|
+
if (!pkg) return out;
|
|
132
|
+
const pm = fs.existsSync(path.join(dir, "pnpm-lock.yaml")) ? "pnpm" : fs.existsSync(path.join(dir, "yarn.lock")) ? "yarn" : fs.existsSync(path.join(dir, "bun.lockb")) || fs.existsSync(path.join(dir, "bun.lock")) ? "bun" : "npm";
|
|
133
|
+
const s = pkg.scripts || {};
|
|
134
|
+
const name = (...ks) => ks.find((k) => s[k]);
|
|
135
|
+
const cmd = (k) => (k ? (pm === "npm" && k === "test" ? "npm test" : `${pm} run ${k}`) : "");
|
|
136
|
+
return { install: `${pm} install`, lint: cmd(name("lint")), typecheck: cmd(name("typecheck", "type-check", "tsc", "check-types")), test: cmd(name("test")), build: cmd(name("build")), dev: cmd(name("dev", "start")) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function gitFacts(dir) {
|
|
140
|
+
if (!isRepo(dir)) return null;
|
|
141
|
+
const def = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], dir).replace(/^origin\//, "") || run("git", ["branch", "--show-current"], dir);
|
|
142
|
+
const branches = run("git", ["for-each-ref", "--sort=-committerdate", "--count=40", "--format=%(refname:short)", "refs/remotes", "refs/heads"], dir).split("\n").filter(Boolean).map((b) => b.replace(/^origin\//, ""));
|
|
143
|
+
const subjects = run("git", ["log", "-40", "--format=%s"], dir).split("\n").filter(Boolean);
|
|
144
|
+
const owner = (run("git", ["remote", "get-url", "origin"], dir).match(/[:/]([^/:]+)\/[^/]+?(\.git)?$/) || [])[1] || "";
|
|
145
|
+
return { def, branches, subjects, owner, integration: ["staging", "develop", "development"].find((b) => branches.includes(b)) || "" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function infer(facts) {
|
|
149
|
+
const branches = facts.flatMap((f) => f.branches);
|
|
150
|
+
const subjects = facts.flatMap((f) => f.subjects);
|
|
151
|
+
const keys = {};
|
|
152
|
+
for (const s of [...branches, ...subjects]) for (const m of s.matchAll(/\b([A-Z][A-Z0-9]{1,9})-\d+\b/g)) keys[m[1]] = (keys[m[1]] || 0) + 1;
|
|
153
|
+
const key = Object.entries(keys).sort((a, b) => b[1] - a[1])[0]?.[0] || "";
|
|
154
|
+
const ghRefs = subjects.filter((s) => /#\d+/.test(s)).length;
|
|
155
|
+
const bare = branches.filter((b) => /^[A-Z][A-Z0-9]+-\d+$/.test(b)).length;
|
|
156
|
+
const work = branches.filter((b) => !["main", "master", "HEAD", "staging", "develop", "development", "pre-prod", "origin"].includes(b)).length || 1;
|
|
157
|
+
const conv = subjects.filter((s) => /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style)(\(.+\))?!?:/.test(s)).length;
|
|
158
|
+
const tick = subjects.filter((s) => /^\[?[A-Z][A-Z0-9]+-\d+/.test(s)).length;
|
|
159
|
+
const n = subjects.length || 1;
|
|
160
|
+
return {
|
|
161
|
+
trackerType: key ? "jira" : ghRefs ? "github" : "none",
|
|
162
|
+
key,
|
|
163
|
+
branchName: key && bare / work >= 0.3 ? "{ticket}" : key ? "{type}/{ticket}-{slug}" : "{type}/{slug}",
|
|
164
|
+
commitStyle: conv / n >= 0.5 ? "conventional" : tick / n >= 0.5 ? "ticket" : "freeform",
|
|
165
|
+
base: facts.filter((f) => f.integration === "staging").length > facts.length / 2 ? "staging" : facts.find((f) => f.integration)?.integration || "default",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const y = (v) => JSON.stringify(v ?? ""); // JSON strings are valid YAML scalars
|
|
170
|
+
function toYaml(c) {
|
|
171
|
+
const cmds = (o, pad) => Object.entries(o).map(([k, v]) => `${pad}${k}: ${y(v)}`).join("\n");
|
|
172
|
+
let out = `# .spec-driven.yml — written by spec-driven-dev setup. Edit freely, or re-run setup.\n# Every spec-* skill reads this first. Field reference: skills/spec-setup/references/config-template.yml\nversion: 1\nlayout: ${c.layout}\n`;
|
|
173
|
+
if (c.repos.length) {
|
|
174
|
+
out += "repos:\n" + c.repos.map((r) => {
|
|
175
|
+
let e = ` - path: ${y(r.path)}\n role: ${y(r.role)}\n`;
|
|
176
|
+
if (r.base && r.base !== c.git.base_branch) e += ` git:\n base_branch: ${y(r.base)}\n pr_target: ${y(r.base)}\n`;
|
|
177
|
+
if (r.commands && Object.values(r.commands).some(Boolean)) e += ` commands:\n${cmds(r.commands, " ")}\n`;
|
|
178
|
+
return e;
|
|
179
|
+
}).join("");
|
|
180
|
+
}
|
|
181
|
+
out += `tracker:\n type: ${c.tracker.type}\n key: ${y(c.tracker.key)}\n url: ${y(c.tracker.url)}\n`;
|
|
182
|
+
out += `git:\n${cmds(c.git, " ")}\n`;
|
|
183
|
+
out += `commands:\n${cmds(c.commands, " ")}\n dev_url: ${y(c.devUrl)}\n`;
|
|
184
|
+
return out + `specs_dir: ${y(c.specsDir)}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function setup() {
|
|
188
|
+
console.log("\nProject setup — detecting how this project works…");
|
|
189
|
+
const d = detectLayout(DIR);
|
|
190
|
+
let repos = d.repos;
|
|
191
|
+
if (d.layout === "multi-repo") {
|
|
192
|
+
const owners = {};
|
|
193
|
+
for (const r of repos) (owners[gitFacts(path.join(d.root, r))?.owner || "?"] ||= []).push(r);
|
|
194
|
+
const biggest = Object.values(owners).sort((a, b) => b.length - a.length)[0] || [];
|
|
195
|
+
console.log(`Found ${repos.length} git repos in ${d.root}. Which ones change together as one workspace?`);
|
|
196
|
+
repos = await pick("Repos", repos.map((r) => ({ value: r, label: `${r} (${Object.keys(owners).find((o) => owners[o].includes(r))})` })), biggest);
|
|
197
|
+
}
|
|
198
|
+
const layout = await ask(`Layout — single, monorepo, or multi-repo (detected: ${d.layout})`, d.layout);
|
|
199
|
+
const root = d.root;
|
|
200
|
+
const file = path.join(root, ".spec-driven.yml");
|
|
201
|
+
if (fs.existsSync(file) && (await ask(`${file} exists — overwrite? (y/n)`, "n")) !== "y") {
|
|
202
|
+
console.log("• Kept the existing config. Run the spec-setup skill in your AI tool to review it.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const repoDirs = layout === "multi-repo" ? repos.map((r) => path.join(root, r)) : [isRepo(root) ? root : DIR];
|
|
207
|
+
const facts = repoDirs.map(gitFacts).filter(Boolean);
|
|
208
|
+
const g = infer(facts);
|
|
209
|
+
if (facts.length) console.log(`Detected: tracker ${g.trackerType}${g.key ? ` (${g.key}-…)` : ""}, base ${g.base}, branches "${g.branchName}", commits ${g.commitStyle}`);
|
|
210
|
+
|
|
211
|
+
const trackerType = await ask("Issue tracker (jira/github/linear/none)", g.trackerType);
|
|
212
|
+
const key = trackerType === "jira" || trackerType === "linear" ? await ask("Ticket key prefix (e.g. STP)", g.key) : "";
|
|
213
|
+
const url = trackerType === "none" ? "" : await ask("Tracker URL (optional)", "");
|
|
214
|
+
const base = await ask('Base branch work is cut from ("default" = repo default branch)', g.base);
|
|
215
|
+
const pr = await ask("Branch PRs target", base);
|
|
216
|
+
const branchName = await ask("Branch name pattern ({ticket} {slug} {type})", g.branchName);
|
|
217
|
+
const commitStyle = await ask("Commit style (conventional/ticket/freeform)", g.commitStyle);
|
|
218
|
+
const specsDir = await ask("Where specs/<feature>/ folders go", "specs");
|
|
219
|
+
|
|
220
|
+
const config = {
|
|
221
|
+
layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "",
|
|
222
|
+
git: { remote: "origin", base_branch: base, pr_target: pr, branch_name: branchName, commit_style: commitStyle },
|
|
223
|
+
commands: layout === "single" ? commandsFor(repoDirs[0]) : commandsFor(root),
|
|
224
|
+
repos: layout === "single" ? [] : (layout === "multi-repo" ? repos : d.repos).map((r) => {
|
|
225
|
+
const f = layout === "multi-repo" ? gitFacts(path.join(root, r)) : null;
|
|
226
|
+
// a repo without an integration branch must say so, or it inherits the top-level base (e.g. staging) it doesn't have
|
|
227
|
+
return { path: r, role: "", base: f ? f.integration || "default" : "", commands: commandsFor(path.join(root, r)) };
|
|
228
|
+
}),
|
|
229
|
+
};
|
|
230
|
+
fs.writeFileSync(file, toYaml(config));
|
|
231
|
+
console.log(`✓ Wrote ${file}`);
|
|
232
|
+
console.log(isRepo(root) ? " Commit it so your team shares the same rules." : " This folder isn't a git repo, so the config is personal to this machine.");
|
|
233
|
+
console.log(' Fill in repo roles, dev_url, and non-Node commands by asking your AI tool to "run spec-setup".');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------- reading the config ----------
|
|
237
|
+
// shortcut: parses the YAML subset setup writes (nested maps, lists of maps, scalars); a full YAML parser if users start hand-writing anchors/flow style.
|
|
238
|
+
function parseYaml(text) {
|
|
239
|
+
const root = {};
|
|
240
|
+
const stack = [{ indent: -1, node: root }];
|
|
241
|
+
const scalar = (v) => {
|
|
242
|
+
v = v.replace(/\s+#.*$/, "").trim();
|
|
243
|
+
if (/^".*"$/.test(v)) return JSON.parse(v);
|
|
244
|
+
if (/^'.*'$/.test(v)) return v.slice(1, -1);
|
|
245
|
+
return v;
|
|
246
|
+
};
|
|
247
|
+
for (const raw of text.split("\n")) {
|
|
248
|
+
if (!raw.trim() || raw.trim().startsWith("#")) continue;
|
|
249
|
+
const indent = raw.search(/\S/);
|
|
250
|
+
let line = raw.trim();
|
|
251
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
|
252
|
+
let parent = stack[stack.length - 1].node;
|
|
253
|
+
if (line.startsWith("- ")) {
|
|
254
|
+
const item = {};
|
|
255
|
+
parent.push(item);
|
|
256
|
+
stack.push({ indent, node: item });
|
|
257
|
+
parent = item;
|
|
258
|
+
line = line.slice(2);
|
|
259
|
+
}
|
|
260
|
+
const m = line.match(/^([\w-]+):\s*(.*)$/);
|
|
261
|
+
if (!m) continue;
|
|
262
|
+
const [, key, val] = m;
|
|
263
|
+
if (val === "") {
|
|
264
|
+
const next = text.split("\n").slice(text.split("\n").indexOf(raw) + 1).find((l) => l.trim() && !l.trim().startsWith("#"));
|
|
265
|
+
parent[key] = next && next.trim().startsWith("- ") ? [] : {};
|
|
266
|
+
stack.push({ indent: line === raw.trim() ? indent : indent + 2, node: parent[key] });
|
|
267
|
+
} else parent[key] = scalar(val);
|
|
268
|
+
}
|
|
269
|
+
return root;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function findConfig(dir) {
|
|
273
|
+
for (let d = dir; ; d = path.dirname(d)) {
|
|
274
|
+
const f = path.join(d, ".spec-driven.yml");
|
|
275
|
+
if (fs.existsSync(f)) return f;
|
|
276
|
+
if (d === path.dirname(d) || d === HOME) return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function doctor() {
|
|
281
|
+
const file = findConfig(DIR);
|
|
282
|
+
if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. Run: npx @benjaminthomas/spec-driven-dev setup`);
|
|
283
|
+
const root = path.dirname(file);
|
|
284
|
+
let c;
|
|
285
|
+
try { c = parseYaml(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(`${file} could not be read: ${e.message}`); }
|
|
286
|
+
const problems = [], notes = [];
|
|
287
|
+
if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
|
|
288
|
+
if (c.tracker && !["jira", "github", "linear", "none"].includes(c.tracker.type)) problems.push(`tracker.type "${c.tracker.type}" must be jira, github, linear, or none`);
|
|
289
|
+
if (c.git?.commit_style && !["conventional", "ticket", "freeform"].includes(c.git.commit_style)) problems.push(`git.commit_style "${c.git.commit_style}" must be conventional, ticket, or freeform`);
|
|
290
|
+
if (c.git?.branch_name && !/\{(ticket|slug)\}/.test(c.git.branch_name)) problems.push(`git.branch_name "${c.git.branch_name}" needs {ticket} or {slug}`);
|
|
291
|
+
const repos = c.layout === "single" ? [{ path: "." }] : Array.isArray(c.repos) ? c.repos : [];
|
|
292
|
+
if (c.layout !== "single" && !repos.length) problems.push(`layout is ${c.layout} but repos: is empty`);
|
|
293
|
+
for (const r of repos) {
|
|
294
|
+
const dir = path.join(root, r.path || "");
|
|
295
|
+
const label = r.path === "." ? "repo" : r.path;
|
|
296
|
+
if (!fs.existsSync(dir)) { problems.push(`${label}: folder not found`); continue; }
|
|
297
|
+
const gitDir = c.layout === "monorepo" ? root : dir;
|
|
298
|
+
if (c.layout !== "monorepo" && !isRepo(gitDir)) { problems.push(`${label}: not a git repo`); continue; }
|
|
299
|
+
if (c.layout === "multi-repo" && !r.role) notes.push(`${label}: no role — investigations search better with one`);
|
|
300
|
+
if (/\(role inferred, verify\)/.test(r.role || "")) notes.push(`${label}: role is still marked "inferred, verify"`);
|
|
301
|
+
for (const k of ["base_branch", "pr_target"]) {
|
|
302
|
+
const b = r.git?.[k] || c.git?.[k];
|
|
303
|
+
if (b && b !== "default" && isRepo(gitDir) && !run("git", ["rev-parse", "--verify", "--quiet", `refs/remotes/${c.git?.remote || "origin"}/${b}`], gitDir) && !run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`], gitDir))
|
|
304
|
+
problems.push(`${label}: git.${k} "${b}" doesn't exist (locally or on ${c.git?.remote || "origin"})`);
|
|
305
|
+
}
|
|
306
|
+
const pkg = readJson(path.join(dir, "package.json"));
|
|
307
|
+
const cmds = { ...(c.layout === "single" ? c.commands : {}), ...(r.commands || {}) };
|
|
308
|
+
for (const [k, v] of Object.entries(cmds)) {
|
|
309
|
+
const script = typeof v === "string" && v.match(/^(?:npm|pnpm|yarn|bun) run ([\w:.-]+)$/)?.[1];
|
|
310
|
+
if (script && pkg && !pkg.scripts?.[script]) problems.push(`${label}: commands.${k} runs "${script}", which isn't in package.json scripts`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const specs = path.join(root, c.specs_dir || "specs");
|
|
314
|
+
if (!fs.existsSync(specs)) notes.push(`${c.specs_dir || "specs"}/ doesn't exist yet — created on first spec`);
|
|
315
|
+
console.log(`Checked ${file}`);
|
|
316
|
+
for (const n of notes) console.log(` • ${n}`);
|
|
317
|
+
for (const p of problems) console.log(` ✗ ${p}`);
|
|
318
|
+
if (problems.length) { process.exitCode = 1; console.log(`${problems.length} problem(s). Fix the file, or ask your AI tool to "run spec-setup".`); }
|
|
319
|
+
else console.log("✓ Config matches the repos.");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Silent unless something is unfinished, so it costs no context in a session start hook.
|
|
323
|
+
function status() {
|
|
324
|
+
const file = findConfig(DIR);
|
|
325
|
+
const root = file ? path.dirname(file) : DIR;
|
|
326
|
+
const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
|
|
327
|
+
const specs = path.join(root, specsName);
|
|
328
|
+
const lines = [];
|
|
329
|
+
for (const name of subdirs(specs)) {
|
|
330
|
+
const readme = path.join(specs, name, "README.md");
|
|
331
|
+
if (!fs.existsSync(readme)) {
|
|
332
|
+
if (fs.existsSync(path.join(specs, name, "investigation.md"))) lines.push(`- ${specsName}/${name}: investigated, no spec yet → next: spec-create`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const t = fs.readFileSync(readme, "utf8");
|
|
336
|
+
const done = (t.match(/^\s*- \[x\]/gim) || []).length, todo = (t.match(/^\s*- \[ \]/gm) || []).length;
|
|
337
|
+
if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: spec-implement`);
|
|
338
|
+
else if (done && !fs.existsSync(path.join(specs, name, "verification-report.md"))) lines.push(`- ${specsName}/${name}: all tasks done, not verified → next: spec-verify`);
|
|
339
|
+
}
|
|
340
|
+
if (lines.length) console.log(`spec-driven-dev: unfinished work in ${root}\n${lines.join("\n")}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (command === "doctor") doctor();
|
|
345
|
+
else if (command === "status") status();
|
|
346
|
+
else if (command === "install") {
|
|
347
|
+
await install();
|
|
348
|
+
if (!flag("no-setup") && (YES || (await ask("\nSet up this project now? (y/n)", "y")) === "y")) await setup();
|
|
349
|
+
} else if (command === "setup") await setup();
|
|
350
|
+
else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
|
|
351
|
+
if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
|
|
352
|
+
} catch (e) {
|
|
353
|
+
console.error(`✗ ${e.message}`);
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
} finally {
|
|
356
|
+
rl?.close();
|
|
357
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@benjaminthomas/spec-driven-dev",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Spec-driven AI development workflow (investigate → spec → implement → verify → ship) as Agent Skills for Claude Code, Cursor, Codex CLI and Antigravity.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "spec-driven-dev": "bin/cli.mjs" },
|
|
7
|
+
"files": ["bin", "skills"],
|
|
8
|
+
"scripts": { "test": "python3 scripts/check.py && node scripts/test-cli.mjs" },
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"keywords": ["agent-skills", "claude-code", "cursor", "codex", "antigravity", "spec-driven-development", "ai-agents"],
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/benjaminthomas/spec-driven-dev.git" },
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"publishConfig": { "access": "public" }
|
|
14
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: generative-seo
|
|
3
|
+
description: Run an evidence-based SEO + GEO (Generative Engine Optimization) program for ANY website or product codebase — auditing technical SEO, writing or retrofitting content so it gets cited by ChatGPT/Perplexity/AI Overviews, tracking AI-citation visibility, refreshing competitor research, and drafting distribution posts. Use whenever the user asks to audit SEO, improve AI-search/AI-citation visibility, write or optimize marketing/blog content for search or GEO, check whether a product shows up in ChatGPT/Perplexity answers, do competitor/keyword research, or wants a monthly SEO performance report — regardless of framework (Next.js, Astro, Remix, plain HTML, WordPress, etc.) or what the product is. Works on any repo; it interviews you once to learn the project's specifics (brand, domain, framework, docs location) and remembers the answers.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Generative SEO (GEO) Operations
|
|
8
|
+
|
|
9
|
+
Runs an SEO + GEO program the same way a sharp in-house SEO operator would: technical
|
|
10
|
+
audits, answer-first content built to be quoted by AI engines, an AI-citation tracker,
|
|
11
|
+
competitor watch, and (draft-only) distribution. It is deliberately generic — first use
|
|
12
|
+
in a repo spends a few minutes learning that project's facts, then behaves like a
|
|
13
|
+
project-specific skill from then on.
|
|
14
|
+
|
|
15
|
+
**Mode selection:** the argument names the mode (`content`, `audit`, `optimize`,
|
|
16
|
+
`competitors`, `citations`, `report`, `social`). No argument → read the project's docs
|
|
17
|
+
(below), summarize current state, and recommend the highest-impact next action.
|
|
18
|
+
|
|
19
|
+
For the full evidence behind every rule below (which studies, what they found, why
|
|
20
|
+
each technique works) and the complete technical checklist, see
|
|
21
|
+
`references/geo-methodology.md`. Read it before an `audit` or before writing a
|
|
22
|
+
first piece of `content` in a new project; skip re-reading it on later runs in the
|
|
23
|
+
same project.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## First run in a project: learn the project
|
|
28
|
+
|
|
29
|
+
Look for a config file at `docs/seo/geo-config.md` (or wherever the user says their
|
|
30
|
+
SEO docs live — some repos keep them in `content/`, `marketing/`, or a wiki instead).
|
|
31
|
+
If it exists, read it and skip to **Read these first**.
|
|
32
|
+
|
|
33
|
+
If it does not exist, this is the first run — ask the user (batch these, don't
|
|
34
|
+
interview one question at a time):
|
|
35
|
+
|
|
36
|
+
1. **Product/brand name(s)** — and critically, if the product is a sub-brand of a
|
|
37
|
+
parent company or a different name than the company that builds it, get that
|
|
38
|
+
distinction explicit now (e.g. "Acme Corp is the company; Acme Flow is the
|
|
39
|
+
product we're marketing" or "same name, no distinction needed"). Getting this
|
|
40
|
+
wrong (writing "is Acme Corp free?" when the product is Acme Flow) is a
|
|
41
|
+
real, recurring mistake — nail it once here instead of guessing per-page later.
|
|
42
|
+
2. **Primary domain** the content lives on (affects sitemap/robots checks and
|
|
43
|
+
internal-link audits).
|
|
44
|
+
3. **Framework** — Next.js, Astro, Remix, SvelteKit, plain static HTML, a CMS
|
|
45
|
+
(WordPress/Webflow/etc.), or something else. This determines where
|
|
46
|
+
sitemap/robots/metadata/JSON-LD live (see `references/geo-methodology.md` for
|
|
47
|
+
the per-framework map) — if unsure, look at the repo yourself first
|
|
48
|
+
(`package.json`, top-level config files) before asking.
|
|
49
|
+
4. **Where marketing/content pages live** in the repo (a directory, a headless
|
|
50
|
+
CMS, or "not in this repo" if content is managed elsewhere).
|
|
51
|
+
5. **Existing design system doc**, if any (a `DESIGN.md`, a Storybook, a Figma
|
|
52
|
+
library) — new/edited pages should match it rather than invent a new look.
|
|
53
|
+
6. **How to source real statistics** for content — product analytics the user can
|
|
54
|
+
query, a public dataset, or "external sources only." Never invent numbers
|
|
55
|
+
regardless of the answer.
|
|
56
|
+
7. **Docs folder** for this skill's own working files (default: `docs/seo/` at
|
|
57
|
+
repo root — offer this default rather than making the user think of one).
|
|
58
|
+
|
|
59
|
+
Write the answers to `<docs_dir>/geo-config.md` using
|
|
60
|
+
`assets/geo-config-template.md` as the shape. Then create the four working docs
|
|
61
|
+
from their templates in `assets/` (only the ones missing — never overwrite an
|
|
62
|
+
existing one):
|
|
63
|
+
|
|
64
|
+
| File | Template | Purpose |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `<docs_dir>/geo-playbook.md` | `assets/geo-playbook-template.md` | This project's evidence-based rules + house style, filled in from the config answers |
|
|
67
|
+
| `<docs_dir>/content-plan.md` | `assets/content-plan-template.md` | The execution backlog with statuses |
|
|
68
|
+
| `<docs_dir>/competitor-analysis.md` | `assets/competitor-analysis-template.md` | Competitor landscape + keyword gaps |
|
|
69
|
+
| `<docs_dir>/citation-log.md` | `assets/citation-log-template.md` | Dated AI-citation audit history |
|
|
70
|
+
|
|
71
|
+
For `citations` mode specifically, also derive a **fixed prompt set**: 10–15
|
|
72
|
+
realistic queries someone would type into ChatGPT/Perplexity/Google when looking
|
|
73
|
+
for a product like this one (category term, "best X for Y" roundups, problem-first
|
|
74
|
+
questions, a couple of named-competitor-alternative queries). Save them into
|
|
75
|
+
`<docs_dir>/citation-log.md`'s header. Keep this list stable run-over-run — append
|
|
76
|
+
new queries, don't rewrite the set, or trend comparison breaks.
|
|
77
|
+
|
|
78
|
+
## Read these first (source of truth — keep them updated every run)
|
|
79
|
+
|
|
80
|
+
- `<docs_dir>/geo-config.md` — project facts (brand, domain, framework, paths)
|
|
81
|
+
- `<docs_dir>/geo-playbook.md` — this project's rules, filled in from the methodology reference
|
|
82
|
+
- `<docs_dir>/content-plan.md` — phased content queue with statuses
|
|
83
|
+
- `<docs_dir>/competitor-analysis.md` — competitor landscape + keyword gap map
|
|
84
|
+
- `<docs_dir>/citation-log.md` — AI-citation audit history + the fixed prompt set
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Non-negotiable content rules (every page you write or edit)
|
|
89
|
+
|
|
90
|
+
Full evidence (Princeton GEO paper, Ahrefs citation studies) in
|
|
91
|
+
`references/geo-methodology.md`. Summary:
|
|
92
|
+
|
|
93
|
+
1. **Answer-first**: the complete, direct answer to the target query lands in the
|
|
94
|
+
first ~200 words. Most AI citations pull from early-page content.
|
|
95
|
+
2. **≥3 statistics, ≥1 quotable expert-voice line, cited sources** per substantive
|
|
96
|
+
page — measurably lifts AI-citation odds. Stats must be real and sourced (see
|
|
97
|
+
the project's config for where "real" comes from) — never invented.
|
|
98
|
+
3. **Extractable structure**: H2/H3s phrased as the questions people actually ask;
|
|
99
|
+
Q&A blocks; comparison tables; a TL;DR/summary box; definition-style openings
|
|
100
|
+
("X is …").
|
|
101
|
+
4. **Self-contained sections**: every H2 section should make sense quoted in
|
|
102
|
+
isolation — AI retrieval is passage-level, not whole-page.
|
|
103
|
+
5. **Author + published date + last-updated date** visible on-page. Refresh
|
|
104
|
+
anything older than ~6 months.
|
|
105
|
+
6. **FAQ block + `FAQPage` JSON-LD** on pricing, feature, and comparison pages.
|
|
106
|
+
7. Tone: practitioner-credible, specific, first-hand. No filler, never keyword-stuff.
|
|
107
|
+
8. Word count follows the query's actual answer depth — never pad, never ship a
|
|
108
|
+
page so thin it can't stand on its own (a rough floor: ~300 words).
|
|
109
|
+
9. Follow the naming/brand distinction captured in `geo-config.md` on every page —
|
|
110
|
+
re-check it before publishing if the page discusses "who makes this."
|
|
111
|
+
10. Match whatever design system this repo already has (`geo-config.md` points at
|
|
112
|
+
it) — a GEO-optimized page that looks out of place hurts more than it helps.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Mode: `audit` — technical SEO + GEO audit (add `--fix` to also apply fixes)
|
|
117
|
+
|
|
118
|
+
Audit BOTH the codebase and the live site (fetch the live URLs — deployed state
|
|
119
|
+
drifts from the repo). Report every failure with file:line. With `--fix`, apply
|
|
120
|
+
code-level fixes and run the project's own check commands (lint/typecheck —
|
|
121
|
+
inspect `package.json` scripts or the repo's build tooling; skip anything that
|
|
122
|
+
doesn't exist rather than assuming `npm run check`).
|
|
123
|
+
|
|
124
|
+
Full checklist (sitemap/robots by framework, JSON-LD schema types, AI-crawler
|
|
125
|
+
allowlist, `llms.txt`, heading hygiene, internal linking, Core Web Vitals sanity)
|
|
126
|
+
is in `references/geo-methodology.md` — pull it up for this mode.
|
|
127
|
+
|
|
128
|
+
**Output:** severity-ordered findings table (blocker / high / med / low), each
|
|
129
|
+
with file:line and the fix. With `--fix`: apply, verify with the project's checks,
|
|
130
|
+
summarize the diff.
|
|
131
|
+
|
|
132
|
+
## Mode: `content` — write the next content piece
|
|
133
|
+
|
|
134
|
+
1. Read `<docs_dir>/content-plan.md`; pick the first `todo` item in the current
|
|
135
|
+
phase (or the item the user names). Flag any unresolved blockers first.
|
|
136
|
+
2. Check the target query's live SERP (web search) — confirm intent, note what
|
|
137
|
+
ranks, find a differentiating angle grounded in what this specific product
|
|
138
|
+
actually does differently (pull that from `geo-config.md` / the codebase, not
|
|
139
|
+
assumption).
|
|
140
|
+
3. Write the piece following the non-negotiable rules above. Comparison pages
|
|
141
|
+
must be honest about competitors — credibility is the citation currency.
|
|
142
|
+
4. Implement it wherever this repo's content actually lives (per `geo-config.md`
|
|
143
|
+
— a route file, a CMS entry, a markdown collection) with full metadata,
|
|
144
|
+
JSON-LD, and design-system compliance.
|
|
145
|
+
5. Add the URL to the project's sitemap mechanism. Update the item's status in
|
|
146
|
+
`content-plan.md` to `shipped (YYYY-MM-DD)`.
|
|
147
|
+
6. Run the project's lint/typecheck/build if it has them. Present the draft for
|
|
148
|
+
review — content is outward-facing; the user approves before it goes live.
|
|
149
|
+
7. AFTER the page is deployed and live, if the project has an IndexNow/Bing-ping
|
|
150
|
+
mechanism, use it (shortens AI-engine discovery from days to hours). If it
|
|
151
|
+
doesn't, mention manual IndexNow submission (bing.com/indexnow) as an option
|
|
152
|
+
rather than inventing tooling. Never ping before deploy.
|
|
153
|
+
|
|
154
|
+
## Mode: `optimize` — GEO-retrofit an existing page
|
|
155
|
+
|
|
156
|
+
Given a URL/path: score it against the non-negotiable rules (answer-first? stats?
|
|
157
|
+
extractable structure? dates? FAQ? schema?), report a before/after checklist, then
|
|
158
|
+
apply improvements preserving the page's voice and design.
|
|
159
|
+
|
|
160
|
+
## Mode: `competitors` — refresh competitor & keyword research
|
|
161
|
+
|
|
162
|
+
1. Re-run research (web search) covering the players already in
|
|
163
|
+
`competitor-analysis.md`, plus new AI-native entrants in the same category.
|
|
164
|
+
2. Check whether the product's category term has a new canonical answer in
|
|
165
|
+
search; check who's cited in relevant "best X" roundups.
|
|
166
|
+
3. Diff against the existing doc: new entrants, keyword-gap changes, threats.
|
|
167
|
+
Append a dated changelog entry — don't remove existing queue items. Suggest
|
|
168
|
+
content-plan additions.
|
|
169
|
+
Cadence: quarterly, or whenever the user senses a new competitor.
|
|
170
|
+
|
|
171
|
+
## Mode: `citations` — AI-visibility audit
|
|
172
|
+
|
|
173
|
+
1. Run the fixed prompt set from `citation-log.md` through web search (captures
|
|
174
|
+
Google/AI-Overview signals) and check Bing presence for the same queries.
|
|
175
|
+
Direct ChatGPT/Perplexity querying usually isn't possible from here — log
|
|
176
|
+
what's checkable and list the 5-minute manual checks for the user with exact
|
|
177
|
+
prompts to paste.
|
|
178
|
+
2. For each query: record who ranks / is cited, whether this product appears
|
|
179
|
+
(distinguish **mention** vs **citation**), and which of the project's own
|
|
180
|
+
pages should be winning it.
|
|
181
|
+
3. Append a dated entry to `citation-log.md`. Report the trend vs. the previous
|
|
182
|
+
entry.
|
|
183
|
+
|
|
184
|
+
## Mode: `report` — periodic performance summary
|
|
185
|
+
|
|
186
|
+
Compile from: citation-log trend, content-plan ship rate, live-site spot checks,
|
|
187
|
+
and (if the user provides Search Console/Bing/GA exports — ask, don't assume
|
|
188
|
+
access) impressions/clicks on target queries. Output: what shipped, what moved,
|
|
189
|
+
top 3 recommended actions for next period. Set realistic expectations: Google
|
|
190
|
+
traction is typically months out from a content ship; Bing/Perplexity move faster
|
|
191
|
+
(weeks).
|
|
192
|
+
|
|
193
|
+
## Mode: `social` — draft distribution posts for shipped content (drafts ONLY)
|
|
194
|
+
|
|
195
|
+
This mode never posts anywhere — automated posting risks ToS violations on
|
|
196
|
+
LinkedIn/Reddit/etc. and the account behind it. Output goes to
|
|
197
|
+
`<docs_dir>/social-queue.md` (create from `assets/social-queue-template.md` on
|
|
198
|
+
first run); the user reviews, pastes, and marks items posted.
|
|
199
|
+
|
|
200
|
+
1. Read `social-queue.md` and `content-plan.md`. Pick shipped pages with no queue
|
|
201
|
+
entry yet — or the page the user names. Newest first, max 3 pages per run.
|
|
202
|
+
2. For each page draft, in the founder/team's actual voice (first person,
|
|
203
|
+
practitioner tone, one concrete stat or claim pulled from the page, minimal
|
|
204
|
+
hashtags, ends with a question or the link — never reads like a press
|
|
205
|
+
release):
|
|
206
|
+
- A personal-profile post for whatever the primary channel is (ask if
|
|
207
|
+
unclear — LinkedIn and X are common defaults).
|
|
208
|
+
- A shorter, product-forward version for a company/brand page, if one exists.
|
|
209
|
+
- If the piece is worth republishing elsewhere (e.g. Medium), a checklist
|
|
210
|
+
entry noting the platform's canonical-URL mechanism must be used — never
|
|
211
|
+
copy-paste in a way that loses canonical attribution.
|
|
212
|
+
- Community threads (Reddit-style forums, Hacker News, niche Slack/Discord
|
|
213
|
+
communities): only where the skill has search budget to find 1–3 live
|
|
214
|
+
threads the page genuinely answers; draft a value-first reply that stands
|
|
215
|
+
on its own without the link, flagged for the user to post manually, in
|
|
216
|
+
their own voice. Never suggest posting the identical comment in 2 places.
|
|
217
|
+
3. Append drafts under a dated heading, one entry per page, with a status marker
|
|
218
|
+
the user flips (`[ ] queued` → `[x] posted YYYY-MM-DD <URL>` or `[-]
|
|
219
|
+
skipped`). Never overwrite prior entries.
|
|
220
|
+
4. Report what was drafted and what's still unposted from previous runs — an
|
|
221
|
+
aging queue means distribution, not content, is the bottleneck.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Automation (scheduled agents)
|
|
226
|
+
|
|
227
|
+
If the harness supports scheduled/cron-style agent runs, these are reasonable
|
|
228
|
+
cadences — set them up only if the user asks, never silently:
|
|
229
|
+
|
|
230
|
+
| Routine | Cadence | Action |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| Content | Weekly | Pick the next queue item, open a PR (never push straight to main) |
|
|
233
|
+
| Citation audit | Monthly | Update the citation log on a branch |
|
|
234
|
+
| Technical audit | Monthly | File findings as a report; PR fixes only for blockers |
|
|
235
|
+
| Competitor refresh | Quarterly | Refresh competitor-analysis.md |
|
|
236
|
+
| Social drafts | Weekly | Draft posts for newly-shipped pages |
|
|
237
|
+
|
|
238
|
+
Scheduled runs always work on a branch + PR — content is outward-facing and needs
|
|
239
|
+
human review before going live. If a run hits an unresolved blocker, report
|
|
240
|
+
rather than plow ahead. Never invent statistics, in scheduled or interactive runs.
|
|
241
|
+
|
|
242
|
+
## Standing cautions
|
|
243
|
+
|
|
244
|
+
- A subdomain is a separate site for authority purposes — don't split content
|
|
245
|
+
onto a subdomain without a deliberate reason; it usually just dilutes signal.
|
|
246
|
+
- Programmatic/templated pages need real data depth per page — thin
|
|
247
|
+
variable-injection pages risk scaled-content-abuse penalties for the whole
|
|
248
|
+
domain.
|
|
249
|
+
- `llms.txt` and schema markup are hygiene, not strategy — don't over-invest
|
|
250
|
+
expecting them alone to move AI-citation odds. The actual lever is answer-first
|
|
251
|
+
content + real stats + third-party mentions (roundups, directories, forums,
|
|
252
|
+
YouTube) + fast indexing (Bing/IndexNow).
|
|
253
|
+
- Anything gated behind a login the skill can't access (Search Console, Bing
|
|
254
|
+
Webmaster Tools, review-site dashboards, social platforms) → give the user a
|
|
255
|
+
precise action list; don't fake having checked it.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# AI Citation Log
|
|
2
|
+
|
|
3
|
+
## Fixed prompt set
|
|
4
|
+
|
|
5
|
+
<!--
|
|
6
|
+
10-15 queries a real prospect would type into ChatGPT / Perplexity / Google
|
|
7
|
+
when looking for a product like this one. Derived once on first `citations`
|
|
8
|
+
run, kept stable so trend comparisons mean something — append, don't rewrite.
|
|
9
|
+
-->
|
|
10
|
+
|
|
11
|
+
-
|
|
12
|
+
|
|
13
|
+
## Audit history
|
|
14
|
+
|
|
15
|
+
| Date | Prompt | Engine | Our status (mention/citation/absent) | Who wins | Note |
|
|
16
|
+
|---|---|---|---|---|---|
|
|
17
|
+
| | | | | | |
|