product-runner 0.5.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.
Files changed (37) hide show
  1. package/README.md +165 -0
  2. package/common/agents/README.md +68 -0
  3. package/common/agents/agente-conceituacao.md +141 -0
  4. package/common/agents/agente-documentacao-funcional.md +107 -0
  5. package/common/agents/agente-gerador-spec.md +106 -0
  6. package/common/agents/agente-kickoff.md +121 -0
  7. package/common/agents/agente-prod-runner.md +107 -0
  8. package/common/agents/agente-review-code.md +97 -0
  9. package/common/agents/agente-review-llm.md +94 -0
  10. package/common/agents/agente-review-product.md +98 -0
  11. package/common/agents/agente-user-review.md +99 -0
  12. package/common/agents/protocolo-de-gates.md +51 -0
  13. package/common/claude-md.template.md +210 -0
  14. package/common/design-principles.md +229 -0
  15. package/common/lessons-learned.md +440 -0
  16. package/common/pipeline.md +143 -0
  17. package/common/spec-guide.md +327 -0
  18. package/common/specs/_open-issues.md +46 -0
  19. package/common/specs/_overview.md +75 -0
  20. package/dist/cli.js +187 -0
  21. package/dist/migrations.js +147 -0
  22. package/dist/scaffold.js +276 -0
  23. package/dist/update.js +400 -0
  24. package/migrations/0.3.0.md +54 -0
  25. package/migrations/0.4.0.md +76 -0
  26. package/migrations/0.5.0.md +55 -0
  27. package/migrations/README.md +68 -0
  28. package/package.json +41 -0
  29. package/profile-cli/README.md +54 -0
  30. package/profile-cli/claude-md.extension.md +102 -0
  31. package/profile-cli/code-patterns.md +363 -0
  32. package/profile-ssr/DESIGN-SYSTEM.md +795 -0
  33. package/profile-ssr/README.md +51 -0
  34. package/profile-ssr/api-patterns.md +70 -0
  35. package/profile-ssr/claude-md.extension.md +113 -0
  36. package/profile-ssr/code-patterns.md +175 -0
  37. package/profile-ssr/ui-patterns.md +97 -0
@@ -0,0 +1,147 @@
1
+ import { readdir, readFile, rename, mkdir, writeFile, access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ /** Nome de arquivo de migration: exatamente `x.y.z.md`. */
5
+ const VERSION_FILE_RE = /^\d+\.\d+\.\d+\.md$/;
6
+ async function exists(p) {
7
+ try {
8
+ await access(p, constants.F_OK);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ /** Compara versoes semver simples (x.y.z): <0, 0, >0. */
16
+ export function compareVersions(a, b) {
17
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
18
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
19
+ for (let i = 0; i < 3; i++) {
20
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
21
+ if (d !== 0)
22
+ return d;
23
+ }
24
+ return 0;
25
+ }
26
+ /** Converte glob simples (`*` num segmento, `**` atravessa) em RegExp. */
27
+ export function globToRegExp(glob) {
28
+ let re = "";
29
+ for (let i = 0; i < glob.length; i++) {
30
+ const c = glob[i];
31
+ if (c === "*") {
32
+ if (glob[i + 1] === "*") {
33
+ if (glob[i + 2] === "/") {
34
+ // `**/` atravessa zero ou mais segmentos (inclui a barra)
35
+ re += "(?:.*/)?";
36
+ i += 2;
37
+ }
38
+ else {
39
+ re += ".*";
40
+ i++;
41
+ }
42
+ }
43
+ else {
44
+ re += "[^/]*";
45
+ }
46
+ }
47
+ else if (/[.+^${}()|[\]\\]/.test(c)) {
48
+ re += "\\" + c;
49
+ }
50
+ else {
51
+ re += c;
52
+ }
53
+ }
54
+ return new RegExp(`^${re}$`);
55
+ }
56
+ /** Parseia uma migration: frontmatter JSON entre `---` + corpo markdown. */
57
+ export function parseMigration(content) {
58
+ const m = content.match(/^\uFEFF?---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
59
+ if (!m) {
60
+ throw new Error("Migration sem frontmatter JSON (--- ... ---).");
61
+ }
62
+ let meta;
63
+ try {
64
+ meta = JSON.parse(m[1]);
65
+ }
66
+ catch (err) {
67
+ throw new Error(`Frontmatter JSON invalido: ${err instanceof Error ? err.message : String(err)}`);
68
+ }
69
+ if (typeof meta.version !== "string") {
70
+ throw new Error('Migration sem campo "version".');
71
+ }
72
+ return {
73
+ version: meta.version,
74
+ previous: typeof meta.previous === "string" ? meta.previous : undefined,
75
+ title: typeof meta.title === "string" ? meta.title : meta.version,
76
+ risk: meta.risk === "high" || meta.risk === "low" ? meta.risk : undefined,
77
+ autoApply: meta.autoApply === true,
78
+ affects: Array.isArray(meta.affects) ? meta.affects : [],
79
+ ops: Array.isArray(meta.ops) ? meta.ops : [],
80
+ body: (m[2] ?? "").trim(),
81
+ };
82
+ }
83
+ /** Le e parseia todas as migrations de um diretorio (arquivos `x.y.z.md`). */
84
+ export async function discoverMigrations(dir) {
85
+ if (!(await exists(dir)))
86
+ return [];
87
+ const out = [];
88
+ for (const name of await readdir(dir)) {
89
+ if (!VERSION_FILE_RE.test(name))
90
+ continue;
91
+ out.push(parseMigration(await readFile(join(dir, name), "utf8")));
92
+ }
93
+ return out.sort((a, b) => compareVersions(a.version, b.version));
94
+ }
95
+ /** Migrations no intervalo (from, to] -- exclusivo no from, inclusivo no to. */
96
+ export function migrationsInSpan(all, from, to) {
97
+ return all
98
+ .filter((mig) => compareVersions(mig.version, from) > 0 &&
99
+ compareVersions(mig.version, to) <= 0)
100
+ .sort((a, b) => compareVersions(a.version, b.version));
101
+ }
102
+ /**
103
+ * Aplica os `ops` mecanicos de uma migration ao projeto (rename/replace).
104
+ * `projectFiles` e a lista atual de arquivos (POSIX, relativos a targetDir),
105
+ * usada para resolver os globs de `replace`.
106
+ */
107
+ export async function applyOps(targetDir, ops, projectFiles) {
108
+ const results = [];
109
+ for (const op of ops) {
110
+ if (op.type === "rename" && op.from && op.to) {
111
+ const fromAbs = join(targetDir, ...op.from.split("/"));
112
+ const toAbs = join(targetDir, ...op.to.split("/"));
113
+ if (await exists(fromAbs)) {
114
+ await mkdir(dirname(toAbs), { recursive: true });
115
+ await rename(fromAbs, toAbs);
116
+ results.push({ op, applied: true, detail: `${op.from} -> ${op.to}` });
117
+ }
118
+ else {
119
+ results.push({ op, applied: false, detail: `${op.from} ausente (no-op)` });
120
+ }
121
+ }
122
+ else if (op.type === "replace" && op.glob && op.find !== undefined) {
123
+ const matcher = globToRegExp(op.glob);
124
+ const find = new RegExp(op.find, "g");
125
+ let count = 0;
126
+ for (const rel of projectFiles) {
127
+ if (!matcher.test(rel))
128
+ continue;
129
+ const abs = join(targetDir, ...rel.split("/"));
130
+ // um op anterior (rename) pode ter movido este arquivo — pula se sumiu
131
+ if (!(await exists(abs)))
132
+ continue;
133
+ const before = await readFile(abs, "utf8");
134
+ const after = before.replace(find, op.replace ?? "");
135
+ if (after !== before) {
136
+ await writeFile(abs, after, "utf8");
137
+ count++;
138
+ }
139
+ }
140
+ results.push({ op, applied: count > 0, detail: `${op.glob}: ${count} arquivo(s)` });
141
+ }
142
+ else {
143
+ results.push({ op, applied: false, detail: `op invalida (${op.type})` });
144
+ }
145
+ }
146
+ return results;
147
+ }
@@ -0,0 +1,276 @@
1
+ import { mkdir, readFile, writeFile, access, readdir } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /** Raiz do pacote (um nível acima de dist/ ou src/), onde vivem os templates. */
7
+ export function templatesRoot() {
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ return resolve(here, "..");
10
+ }
11
+ /** Arquivos de template que NÃO vão pra docs/ — são mesclados no CLAUDE.md. */
12
+ const CLAUDE_MD_PARTS = new Set([
13
+ "claude-md.template.md",
14
+ "claude-md.extension.md",
15
+ ]);
16
+ async function exists(path) {
17
+ try {
18
+ await access(path, constants.F_OK);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /**
26
+ * Remove o blockquote de aviso "este é um template / extensão" do topo de um
27
+ * fragmento de CLAUDE.md — instrução de uso que não faz sentido no arquivo final.
28
+ */
29
+ function stripTemplateNotice(md) {
30
+ const lines = md.split("\n");
31
+ const out = [];
32
+ let i = 0;
33
+ while (i < lines.length) {
34
+ const line = lines[i];
35
+ const isNoticeStart = line.startsWith(">") && /\btemplate\b|\bextens[ãa]o\b/i.test(line);
36
+ if (isNoticeStart) {
37
+ // pula o bloco de citação inteiro (linhas começando com ">")
38
+ while (i < lines.length && lines[i].startsWith(">"))
39
+ i++;
40
+ // pula uma linha em branco logo após o bloco, se houver
41
+ if (i < lines.length && lines[i].trim() === "")
42
+ i++;
43
+ continue;
44
+ }
45
+ out.push(line);
46
+ i++;
47
+ }
48
+ return out.join("\n");
49
+ }
50
+ function applySubstitutions(md, sub) {
51
+ return md.replaceAll("{PROJECT_NAME}", sub.name).replaceAll("{PORT}", sub.port);
52
+ }
53
+ const DIRECTIVE_RE = /^<!--\s*prod-runner-merge:\s*(replace|append|after)\s+section="([^"]+)"\s*-->\s*$/;
54
+ function parseDirectives(extension) {
55
+ const directives = [];
56
+ let current = null;
57
+ let buf = [];
58
+ const flush = () => {
59
+ if (current) {
60
+ current.content = buf.join("\n").replace(/^\n+|\n+$/g, "");
61
+ directives.push(current);
62
+ }
63
+ };
64
+ for (const line of extension.split("\n")) {
65
+ const m = line.match(DIRECTIVE_RE);
66
+ if (m) {
67
+ flush();
68
+ current = { mode: m[1], section: m[2], content: "" };
69
+ buf = [];
70
+ }
71
+ else if (current) {
72
+ buf.push(line);
73
+ }
74
+ }
75
+ flush();
76
+ return directives;
77
+ }
78
+ /** Nível (1-6) de um heading ATX, ou null se a linha não for heading. */
79
+ function headingLevel(line) {
80
+ const m = line.match(/^(#{1,6})\s+/);
81
+ return m ? m[1].length : null;
82
+ }
83
+ /**
84
+ * Localiza uma seção pelo texto do heading. Retorna [start, end) em índices de
85
+ * linha; `end` é a próxima heading de nível menor-ou-igual (ou o fim do doc).
86
+ */
87
+ function findSection(lines, heading) {
88
+ let start = -1;
89
+ let level = 0;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const lvl = headingLevel(lines[i]);
92
+ if (lvl !== null && lines[i].replace(/^#{1,6}\s+/, "").trim() === heading) {
93
+ start = i;
94
+ level = lvl;
95
+ break;
96
+ }
97
+ }
98
+ if (start === -1) {
99
+ throw new Error(`Diretiva prod-runner-merge: seção "${heading}" não existe no CLAUDE.md base.`);
100
+ }
101
+ let end = lines.length;
102
+ for (let i = start + 1; i < lines.length; i++) {
103
+ const lvl = headingLevel(lines[i]);
104
+ if (lvl !== null && lvl <= level) {
105
+ end = i;
106
+ break;
107
+ }
108
+ }
109
+ return { start, end };
110
+ }
111
+ function applyDirective(lines, d) {
112
+ const { start, end } = findSection(lines, d.section);
113
+ const before = lines.slice(0, start);
114
+ const section = lines.slice(start, end);
115
+ const after = lines.slice(end);
116
+ const content = d.content.split("\n");
117
+ if (d.mode === "replace") {
118
+ return [...before, ...content, "", ...after];
119
+ }
120
+ if (d.mode === "after") {
121
+ return [...before, ...section, ...content, "", ...after];
122
+ }
123
+ // append: corpo da seção, sem linhas em branco finais, + conteúdo novo
124
+ const body = [...section];
125
+ while (body.length && body[body.length - 1].trim() === "")
126
+ body.pop();
127
+ return [...before, ...body, "", ...content, "", ...after];
128
+ }
129
+ /** Dobra a extensão do perfil no template-base via diretivas prod-runner-merge. */
130
+ function mergeClaudeMd(base, extension) {
131
+ let lines = base.split("\n");
132
+ for (const directive of parseDirectives(extension)) {
133
+ lines = applyDirective(lines, directive);
134
+ }
135
+ // colapsa 3+ linhas em branco seguidas em no máximo 1 (resultado limpo)
136
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
137
+ }
138
+ /** Nome do manifesto escrito em docs/ — base para futuros `update`. */
139
+ export const MANIFEST_FILENAME = ".product-runner.json";
140
+ export function sha256(content) {
141
+ return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
142
+ }
143
+ /** Lista recursiva de arquivos (caminhos relativos POSIX) sob `dir`. */
144
+ export async function listFiles(dir, base = dir) {
145
+ const entries = await readdir(dir, { withFileTypes: true });
146
+ const out = [];
147
+ for (const entry of entries) {
148
+ const full = join(dir, entry.name);
149
+ if (entry.isDirectory()) {
150
+ out.push(...(await listFiles(full, base)));
151
+ }
152
+ else {
153
+ out.push(relative(base, full).split(sep).join("/"));
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+ /** Versão do pacote (do package.json na raiz do pacote). */
159
+ export async function packageVersion(root) {
160
+ const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
161
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
162
+ }
163
+ /**
164
+ * Calcula (SEM escrever) todos os arquivos que o scaffold emitiria, como um
165
+ * mapa destPath(relativo ao targetDir, POSIX) → Artifact. É a fonte única usada
166
+ * tanto pelo `scaffold` (escreve) quanto pelo `update` (compara). Garante que a
167
+ * "base" registrada no manifesto seja byte-idêntica ao que seria gerado.
168
+ */
169
+ export async function buildArtifacts(meta) {
170
+ const root = templatesRoot();
171
+ const commonDir = join(root, "common");
172
+ const profileDir = join(root, `profile-${meta.profile}`);
173
+ const out = new Map();
174
+ // common/ e perfil → docs/, exceto: fragmentos do CLAUDE.md (pulados) e
175
+ // common/specs/* → specs/ (seeds preenchidos no projeto, fora de docs/).
176
+ for (const [srcDir, prefix] of [
177
+ [commonDir, "common"],
178
+ [profileDir, `profile-${meta.profile}`],
179
+ ]) {
180
+ for (const rel of await listFiles(srcDir)) {
181
+ const base = rel.split("/").pop() ?? "";
182
+ if (CLAUDE_MD_PARTS.has(base))
183
+ continue;
184
+ // docs são copiados sem substituição → conteúdo emitido == origem
185
+ const content = await readFile(join(srcDir, rel), "utf8");
186
+ const dest = rel.startsWith("specs/") ? rel : `docs/${rel}`;
187
+ out.set(dest, { content, fromTemplate: `${prefix}/${rel}` });
188
+ }
189
+ }
190
+ // CLAUDE.md = base com a extensão do perfil dobrada via diretivas
191
+ const template = await readFile(join(commonDir, "claude-md.template.md"), "utf8");
192
+ const extension = await readFile(join(profileDir, "claude-md.extension.md"), "utf8");
193
+ const merged = applySubstitutions(mergeClaudeMd(stripTemplateNotice(template), extension), meta).trimEnd() + "\n";
194
+ out.set("CLAUDE.md", {
195
+ content: merged,
196
+ fromTemplate: `merge:common/claude-md.template.md+profile-${meta.profile}/claude-md.extension.md`,
197
+ });
198
+ return out;
199
+ }
200
+ /**
201
+ * Escreve o manifesto em docs/ a partir dos artefatos emitidos. O hash de cada
202
+ * arquivo é do conteúdo EMITIDO — vira a "base" que torna o `update` 3-way.
203
+ */
204
+ export async function writeManifest(docsPath, meta, artifacts) {
205
+ const files = {};
206
+ for (const [path, art] of artifacts) {
207
+ files[path] = { fromTemplate: art.fromTemplate, sha256: sha256(art.content) };
208
+ }
209
+ const manifest = {
210
+ manifestVersion: 1,
211
+ package: "product-runner",
212
+ version: await packageVersion(templatesRoot()),
213
+ profile: meta.profile,
214
+ projectName: meta.name,
215
+ port: meta.port,
216
+ files: Object.fromEntries(Object.keys(files)
217
+ .sort()
218
+ .map((k) => [k, files[k]])),
219
+ };
220
+ const manifestPath = join(docsPath, MANIFEST_FILENAME);
221
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
222
+ return manifestPath;
223
+ }
224
+ export async function scaffold(opts) {
225
+ const docsPath = join(opts.targetDir, "docs");
226
+ const claudeMdPath = join(opts.targetDir, "CLAUDE.md");
227
+ if (!opts.force) {
228
+ for (const p of [docsPath, claudeMdPath]) {
229
+ if (await exists(p)) {
230
+ throw new Error(`Já existe "${p}". Use --force para sobrescrever ou escolha outro --dir.`);
231
+ }
232
+ }
233
+ }
234
+ const artifacts = await buildArtifacts(opts);
235
+ // escreve cada artefato no destino (docs/* e CLAUDE.md na raiz)
236
+ for (const [rel, art] of artifacts) {
237
+ const dest = join(opts.targetDir, ...rel.split("/"));
238
+ await mkdir(dirname(dest), { recursive: true });
239
+ await writeFile(dest, art.content, "utf8");
240
+ }
241
+ const manifestPath = await writeManifest(docsPath, opts, artifacts);
242
+ return { claudeMdPath, docsPath, manifestPath };
243
+ }
244
+ /** Agente que o humano deve abrir após o init ("leia <este> e siga"). */
245
+ export const ENTRY_AGENT = "agente-prod-runner.md";
246
+ /**
247
+ * Par de bootstrap colocado na raiz pelo `init`: o agente de entrada (roteador
248
+ * + ciclo de vida) e o de discovery, que o roteador aciona em projeto novo.
249
+ * Ambos voltam a viver em docs/agents/ após o scaffold.
250
+ */
251
+ export const BOOTSTRAP_AGENTS = [ENTRY_AGENT, "agente-kickoff.md"];
252
+ /**
253
+ * Copia o par de agentes de bootstrap (de common/agents/) para a raiz do
254
+ * projeto. O ponto de entrada é o `agente-prod-runner.md`: a LLM o lê, diagnostica o
255
+ * estado do projeto e roteia (discovery, conceituação, adoção legada…).
256
+ */
257
+ export async function initProject(opts) {
258
+ const agentsDir = join(templatesRoot(), "common", "agents");
259
+ const dests = BOOTSTRAP_AGENTS.map((name) => join(opts.targetDir, name));
260
+ if (!opts.force) {
261
+ for (const dest of dests) {
262
+ if (await exists(dest)) {
263
+ throw new Error(`Já existe "${dest}". Use --force para sobrescrever.`);
264
+ }
265
+ }
266
+ }
267
+ await mkdir(opts.targetDir, { recursive: true });
268
+ const files = [];
269
+ for (const name of BOOTSTRAP_AGENTS) {
270
+ const content = await readFile(join(agentsDir, name), "utf8");
271
+ const dest = join(opts.targetDir, name);
272
+ await writeFile(dest, content, "utf8");
273
+ files.push(dest);
274
+ }
275
+ return { entryPath: join(opts.targetDir, ENTRY_AGENT), files };
276
+ }