sinapse-ai 1.16.0 → 1.17.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 (35) hide show
  1. package/.claude/hooks/doc-first-gate.cjs +156 -0
  2. package/.claude/hooks/enforce-story-gate.cjs +6 -2
  3. package/.claude/rules/hook-governance.md +1 -0
  4. package/.sinapse-ai/constitution.md +1 -1
  5. package/.sinapse-ai/core/atlas/atlas-data.js +278 -0
  6. package/.sinapse-ai/core/atlas/flows-pt.js +253 -0
  7. package/.sinapse-ai/core/atlas/flows.js +266 -0
  8. package/.sinapse-ai/core/atlas/index.js +62 -0
  9. package/.sinapse-ai/core/atlas/render-html.js +216 -0
  10. package/.sinapse-ai/core/atlas/render-markdown.js +313 -0
  11. package/.sinapse-ai/core/atlas/render-research-card.js +164 -0
  12. package/.sinapse-ai/core/orchestration/build-command.js +136 -0
  13. package/.sinapse-ai/core/orchestration/doc-first-resolver.js +322 -0
  14. package/.sinapse-ai/core/orchestration/route-command.js +97 -0
  15. package/.sinapse-ai/data/entity-registry.yaml +59 -14
  16. package/.sinapse-ai/development/agents/snps-orqx.md +9 -0
  17. package/.sinapse-ai/development/tasks/create-doc.md +1 -1
  18. package/.sinapse-ai/development/tasks/create-next-story.md +2 -3
  19. package/.sinapse-ai/development/tasks/spec-gather-requirements.md +11 -0
  20. package/.sinapse-ai/development/templates/approval-table.md +106 -0
  21. package/.sinapse-ai/development/workflows/spec-pipeline.yaml +1 -0
  22. package/.sinapse-ai/install-manifest.yaml +60 -16
  23. package/.sinapse-ai/product/templates/prd-tmpl.yaml +6 -2
  24. package/CHANGELOG.md +18 -2
  25. package/bin/sinapse.js +121 -0
  26. package/docs/framework/atlas/OPERATING-ATLAS.md +537 -0
  27. package/docs/framework/atlas/README.md +34 -0
  28. package/docs/framework/atlas/atlas-data.json +2810 -0
  29. package/docs/framework/atlas/atlas.html +392 -0
  30. package/package.json +1 -1
  31. package/packages/installer/src/wizard/ide-config-generator.js +6 -0
  32. package/scripts/install-chrome-brain.sh +2 -2
  33. package/scripts/validate-no-personal-leaks.js +24 -9
  34. package/squads/squad-copy/knowledge-base/consequence-headline-patterns.md +2 -2
  35. package/docs/guides/hooks-two-layers.md +0 -66
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Render — SINAPSE research card (PT-BR).
3
+ *
4
+ * Emits the SINAPSE framework as a case-study card in the same template as the
5
+ * engineering-software research corpus (domain cards + LOOPS-*-diagramas.md),
6
+ * fully in Portuguese. One source (the live atlas `data` + flows-pt translations)
7
+ * → two markdown files, so the card never drifts from how the framework actually
8
+ * works. Regenerated by `sinapse atlas --research-card`.
9
+ *
10
+ * Pure string builder: no fs, no Date — caller passes data.generatedAt and writes.
11
+ *
12
+ * @module core/atlas/render-research-card
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const { FRAMEWORK_FLOWS_PT } = require('./flows-pt');
18
+
19
+ /** Roman-numeral severity → short PT label is not needed; severity is kept verbatim. */
20
+
21
+ /**
22
+ * Build the SINAPSE case-study card markdown (PT-BR).
23
+ * @param {object} data - atlas data (buildAtlasData output)
24
+ * @param {Record<string,object>} pt - flow id → PT translation
25
+ * @returns {string}
26
+ */
27
+ function buildCard(data, pt) {
28
+ const c = data.counts;
29
+ const date = (data.generatedAt || '').slice(0, 10);
30
+ const flows = data.flows || [];
31
+
32
+ const flowList = flows
33
+ .map((f) => {
34
+ const t = pt[f.id] || { titulo: f.title, proposito: f.purpose };
35
+ return `- **${t.titulo}** — ${t.proposito}`;
36
+ })
37
+ .join('\n');
38
+
39
+ const articleRows = (data.articles || [])
40
+ .map((a) => `| ${a.number} | ${a.title} | ${a.severity} |`)
41
+ .join('\n');
42
+
43
+ return `---
44
+ titulo: "SINAPSE — Framework de Engenharia Orientada por IA (estudo de caso)"
45
+ tipo: estudo-de-caso
46
+ dominio: sinapse-framework
47
+ status: instancia-viva
48
+ prioridade: alta
49
+ created: ${date}
50
+ gerado_por: "sinapse atlas --research-card (auto-regenerável a partir do disco)"
51
+ ---
52
+
53
+ # SINAPSE — Framework de Engenharia Orientada por IA
54
+
55
+ > O SINAPSE é a instância viva dos domínios desta pesquisa: um meta-framework que impõe processo (spec antes de código, gates inegociáveis, delegação a especialistas) e orquestra ${c.agentsTotal} agentes em ${c.squads} squads para construir software com rigor.
56
+ > Este card é gerado do próprio framework (\`sinapse atlas\`), então descreve o que ele **realmente** faz hoje — não uma intenção desatualizada.
57
+
58
+ ## Por que este estudo de caso importa
59
+
60
+ Os ${60} domínios desta pesquisa descrevem **como** construir software de qualidade com IA. O SINAPSE é uma resposta concreta a "como operacionalizar tudo isso de forma reproduzível": transforma princípios (Building Effective Agents, spec-driven development, context engineering, evals como gate) em **enforcement automático** — hooks que bloqueiam, gates que barram, orquestradores que delegam. Ler este card lado a lado com os domínios mostra a ponte entre teoria de engenharia aumentada por IA e um sistema sociotécnico em produção.
61
+
62
+ ## O que é o SINAPSE (números exatos, do disco)
63
+
64
+ | Dimensão | Quantidade |
65
+ |---|---:|
66
+ | Squads | ${c.squads} |
67
+ | Agentes (total) | ${c.agentsTotal} |
68
+ | — de framework | ${c.frameworkAgents} |
69
+ | — de squad | ${c.squadAgents} |
70
+ | Orquestradores (-orqx) | ${c.orqx} |
71
+ | Workflows | ${c.workflowsTotal} |
72
+ | Artigos da constituição | ${c.articles} |
73
+ | Regras ativas | ${c.rules} |
74
+
75
+ ## Como funciona — a cada pedido, prompt, execução e orquestração
76
+
77
+ O SINAPSE é descrito por ${flows.length} meta-fluxos. Cada um é um mecanismo real do framework (não há invenção — todos rastreiam para uma regra/hook). Os diagramas em PT-BR estão em \`LOOPS-sinapse-diagramas.md\`.
78
+
79
+ ${flowList}
80
+
81
+ ## Princípios constitucionais (gates inegociáveis)
82
+
83
+ A constituição tem ${c.articles} artigos. Gates automáticos bloqueiam violações.
84
+
85
+ | Art. | Princípio | Severidade |
86
+ |---|---|---|
87
+ ${articleRows}
88
+
89
+ ## Catálogo de workflows
90
+
91
+ ${c.workflowsTotal} workflows no total — ${c.frameworkWorkflows} de framework (orquestração, doc-first, greenfield/brownfield, QA) e ${c.squadWorkflows} de squad (expertise de domínio). O catálogo completo (com arquivo-fonte) vive no Atlas em \`docs/framework/atlas/OPERATING-ATLAS.md\`.
92
+
93
+ ## Os meta-fluxos visuais
94
+
95
+ Todos os ${flows.length} diagramas Mermaid, em PT-BR, em **\`LOOPS-sinapse-diagramas.md\`** — no mesmo formato dos arquivos \`LOOPS-*-diagramas.md\` desta pesquisa.
96
+
97
+ ---
98
+
99
+ *Card gerado por \`sinapse atlas --research-card\` em ${date}. Fonte única → não editar à mão (re-rode o gerador).*
100
+ `;
101
+ }
102
+
103
+ /**
104
+ * Build the LOOPS-sinapse-diagramas markdown (PT-BR): every flow as a Mermaid block.
105
+ * @param {object} data - atlas data
106
+ * @param {Record<string,object>} pt - flow id → PT translation
107
+ * @returns {string}
108
+ */
109
+ function buildLoops(data, pt) {
110
+ const date = (data.generatedAt || '').slice(0, 10);
111
+ const flows = data.flows || [];
112
+
113
+ const blocks = flows
114
+ .map((f, i) => {
115
+ const t = pt[f.id] || { titulo: f.title, proposito: f.purpose, mermaid: f.mermaid };
116
+ const mermaid = t.mermaid || f.mermaid;
117
+ return `## MF${i + 1} — ${t.titulo}
118
+
119
+ > ${t.proposito}
120
+
121
+ \`\`\`mermaid
122
+ ${mermaid}
123
+ \`\`\`
124
+ `;
125
+ })
126
+ .join('\n');
127
+
128
+ return `---
129
+ titulo: "LOOPS — Meta-fluxos do SINAPSE (estudo de caso)"
130
+ tipo: loops-diagramas
131
+ dominio: sinapse-framework
132
+ status: instancia-viva
133
+ created: ${date}
134
+ total_diagramas: ${flows.length}
135
+ ---
136
+
137
+ # LOOPS — Meta-fluxos do SINAPSE
138
+
139
+ > Os ${flows.length} fluxos operacionais do framework SINAPSE em Mermaid — como ele funciona a cada pedido, prompt, execução e orquestração. Renderizam direto no GitHub.
140
+ > Gerado por \`sinapse atlas --research-card\`; fonte única no próprio framework.
141
+
142
+ ${blocks}
143
+ ---
144
+
145
+ *Gerado em ${date}. Re-rode \`sinapse atlas --research-card\` para atualizar.*
146
+ `;
147
+ }
148
+
149
+ /**
150
+ * Render both PT-BR research artifacts from atlas data.
151
+ * @param {object} data - atlas data (buildAtlasData output)
152
+ * @param {object} [opts]
153
+ * @param {Record<string,object>} [opts.pt] - flow translations (defaults to FRAMEWORK_FLOWS_PT)
154
+ * @returns {{ card: string, loops: string }}
155
+ */
156
+ function renderResearchCard(data, opts = {}) {
157
+ const pt = opts.pt || FRAMEWORK_FLOWS_PT;
158
+ return {
159
+ card: buildCard(data, pt),
160
+ loops: buildLoops(data, pt),
161
+ };
162
+ }
163
+
164
+ module.exports = { renderResearchCard };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Build Command — executable sibling of `sinapse route`.
3
+ *
4
+ * `route()` SHOWS the deterministic doc-first decision for a briefing (type →
5
+ * workflow → required docs → gate). `build()` RUNS it: it drives the
6
+ * BobOrchestrator decision-tree engine (Projeto Bob) from a briefing in GUIDED
7
+ * mode — it detects project state, routes to the greenfield / brownfield /
8
+ * existing-project handler, and surfaces the next action/checkpoint instead of
9
+ * spawning agents headlessly.
10
+ *
11
+ * Congruence guarantee: both `route()` and `build()` start from
12
+ * `resolveDocFirstState`, so the conversation, the doc-first gate hook, `route`
13
+ * and `build` can never disagree about type/workflow/gate.
14
+ *
15
+ * The heavy orchestration index (locks, observability panel, status writer) is
16
+ * required LAZILY — `--dry-run` never constructs it, so it stays side-effect-free.
17
+ *
18
+ * @module core/orchestration/build-command
19
+ * @version 1.0.0
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { resolveDocFirstState } = require('./doc-first-resolver');
25
+
26
+ let chalk;
27
+ try {
28
+ chalk = require('chalk');
29
+ } catch {
30
+ const id = (s) => s;
31
+ chalk = {
32
+ green: id, red: id, yellow: Object.assign(id, { bold: id }),
33
+ cyan: Object.assign(id, { bold: id }), gray: id, bold: id, dim: id,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Render the compact doc-first routing header (the "plan"), shared with `route`
39
+ * so `build` is visibly congruent with it.
40
+ * @param {string} brief
41
+ * @param {object} state - resolveDocFirstState() result
42
+ */
43
+ function printPlanHeader(brief, state) {
44
+ const ok = (b) => (b ? chalk.green('✓') : chalk.red('✗'));
45
+ const briefLabel = brief ? `"${brief}"` : '(no brief — using project state only)';
46
+
47
+ console.log(chalk.cyan('\n═══════════════════════════════════════════════════════════'));
48
+ console.log(chalk.cyan.bold(' 🛠️ SINAPSE Build — guided orchestration'));
49
+ console.log(chalk.cyan('═══════════════════════════════════════════════════════════\n'));
50
+
51
+ console.log(`Brief: ${chalk.gray(briefLabel)}`);
52
+ console.log(`Project type: ${chalk.bold(state.projectType)}`);
53
+ console.log(`Workflow: ${chalk.bold(state.workflow || 'SDC (no upstream re-doc)')}`);
54
+ console.log(
55
+ `Doc-first: ${state.gate.satisfied
56
+ ? chalk.green('✅ gate satisfied')
57
+ : chalk.red('🚫 gate blocked — missing: ' + state.gate.missing.join(', '))}\n`,
58
+ );
59
+ void ok;
60
+ }
61
+
62
+ /**
63
+ * Render the BobOrchestrator result in a guided, scannable way.
64
+ * @param {object} result - OrchestrationResult from BobOrchestrator.orchestrate
65
+ */
66
+ function printEngineResult(result) {
67
+ const r = result || {};
68
+ console.log(chalk.bold('Engine decision:'));
69
+ console.log(` Project state: ${chalk.bold(r.projectState || 'unknown')}`);
70
+ console.log(` Action: ${chalk.bold(r.action || 'n/a')}`);
71
+
72
+ const data = r.data || {};
73
+ const nextStep = r.nextStep || data.nextStep;
74
+ const message = data.formattedMessage || data.message || data.summary;
75
+ if (message) {
76
+ console.log('\n' + chalk.gray(String(message).trim()));
77
+ }
78
+ if (nextStep) {
79
+ console.log(`\n${chalk.cyan('▶ Next step:')} ${chalk.bold(nextStep)}`);
80
+ }
81
+
82
+ console.log('');
83
+ if (r.success) {
84
+ console.log(chalk.green.bold(' ✅ Orchestration step completed.\n'));
85
+ } else {
86
+ console.log(chalk.red.bold(` 🚫 Orchestration did not complete: ${r.error || r.action || 'unknown'}\n`));
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Execute the `build` command.
92
+ *
93
+ * @param {string} brief - User briefing (e.g. "criar um site pra X")
94
+ * @param {Object} [options]
95
+ * @param {string} [options.projectRoot] - Project root path
96
+ * @param {string} [options.type] - Explicit project_type override
97
+ * @param {boolean} [options.dryRun] - Show the plan + engine entry without running it
98
+ * @returns {Promise<{success:boolean, exitCode:number, state:object, result?:object}>}
99
+ */
100
+ async function build(brief, options = {}) {
101
+ const projectRoot = options.projectRoot || process.cwd();
102
+ const state = resolveDocFirstState({ projectRoot, brief, projectType: options.type });
103
+
104
+ printPlanHeader(brief, state);
105
+
106
+ if (options.dryRun) {
107
+ console.log(chalk.yellow.bold(' (dry-run) '), 'would run BobOrchestrator.orchestrate({ userGoal })');
108
+ console.log(
109
+ chalk.gray(' No engine constructed — no locks, no panel, no side effects.\n'),
110
+ );
111
+ return { success: true, exitCode: 0, state };
112
+ }
113
+
114
+ // Lazily require the heavy orchestration index ONLY for the real run.
115
+ let BobOrchestrator;
116
+ try {
117
+ ({ BobOrchestrator } = require('./index'));
118
+ } catch (error) {
119
+ console.log(chalk.red(` ✗ Could not load orchestration engine: ${error.message}\n`));
120
+ return { success: false, exitCode: 1, state };
121
+ }
122
+
123
+ const bob = new BobOrchestrator(projectRoot, { userGoal: brief });
124
+ const result = await bob.orchestrate({ userGoal: brief, storyPath: options.storyPath });
125
+
126
+ printEngineResult(result);
127
+
128
+ return {
129
+ success: !!(result && result.success),
130
+ exitCode: result && result.success === false ? 1 : 0,
131
+ state,
132
+ result,
133
+ };
134
+ }
135
+
136
+ module.exports = { build };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Doc-First Resolver — deterministic "what does this project need before code?"
3
+ *
4
+ * Single source of truth for the Documentation-First contract (Constitution
5
+ * Article III). Given a project root (and optionally a user brief), it answers,
6
+ * with NO LLM reasoning and NO side effects:
7
+ *
8
+ * - projectType : site | lp | app | platform | saas | api | service | feature | fix
9
+ * - workflow : which greenfield workflow applies (reuses greenfield-handler map)
10
+ * - artifacts : the upstream docs required before stories (brief/prd/spec/arch)
11
+ * - gate : is the minimal doc-first gate satisfied? (PRD + epic + a Ready story)
12
+ *
13
+ * Two consumers share this module so they never drift:
14
+ * 1. `sinapse route "<brief>"` — read-only observability of the routing decision.
15
+ * 2. The doc-first PreToolUse gate hook — blocks code writes when `gate.satisfied`
16
+ * is false. The module is intentionally dependency-light (fs/path + the
17
+ * greenfield-handler constants only) so it is safe and fast to require inside
18
+ * a hook. It NEVER constructs the heavy BobOrchestrator (locks, panels, etc.).
19
+ *
20
+ * @module core/orchestration/doc-first-resolver
21
+ * @version 1.0.0
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const {
30
+ GREENFIELD_WORKFLOW_BY_TYPE,
31
+ resolveGreenfieldWorkflow,
32
+ } = require('./greenfield-handler');
33
+
34
+ // ═══════════════════════════════════════════════════════════════════════════════════
35
+ // PROJECT TYPE CLASSIFICATION
36
+ // ═══════════════════════════════════════════════════════════════════════════════════
37
+
38
+ /**
39
+ * Keyword → project_type map. Mirrors the classification matrix in
40
+ * `.claude/rules/documentation-first.md` and `project-intelligence.md`.
41
+ * Order matters: more specific buckets are tested before broader ones.
42
+ * @constant
43
+ */
44
+ const TYPE_KEYWORDS = Object.freeze([
45
+ { type: 'fix', words: ['bug', 'conserta', 'corrige', 'corrigir', 'ajusta', 'ajustar', 'tweak', 'hotfix'] },
46
+ { type: 'refactor', words: ['refatora', 'refatorar', 'refactor', 'renomeia', 'renomear', 'limpa', 'cleanup'] },
47
+ { type: 'lp', words: ['landing page', 'landing', 'lp', 'sales page', 'captura', 'squeeze'] },
48
+ { type: 'saas', words: ['saas', 'software as a service', 'multi-tenant', 'multitenant'] },
49
+ { type: 'platform', words: ['plataforma', 'platform', 'dashboard', 'admin', 'portal', 'painel'] },
50
+ { type: 'app', words: ['app mobile', 'mobile', 'ios', 'android', 'react native', 'flutter', 'aplicativo'] },
51
+ { type: 'api', words: ['api', 'backend', 'microservice', 'microsserviço', 'rest', 'graphql'] },
52
+ { type: 'service', words: ['worker', 'cron', 'etl', 'integration', 'integração', 'automation', 'automação'] },
53
+ { type: 'site', words: ['site', 'website', 'web site', 'institucional', 'página', 'pagina'] },
54
+ { type: 'feature', words: ['feature', 'funcionalidade', 'implementa', 'implementar', 'adiciona'] },
55
+ ]);
56
+
57
+ /** Escape a string for safe use inside a RegExp. */
58
+ function escapeRegExp(s) {
59
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ }
61
+
62
+ /**
63
+ * Classify a brief string into a project_type. Returns `null` when the brief is
64
+ * empty or no keyword matches (caller decides the fallback). Matching is by whole
65
+ * word/phrase boundary (so "Asaas" does NOT match the keyword "saas").
66
+ * @param {string} [brief]
67
+ * @returns {string|null}
68
+ */
69
+ function classifyProjectType(brief) {
70
+ if (!brief || typeof brief !== 'string') return null;
71
+ const haystack = brief.toLowerCase();
72
+ for (const { type, words } of TYPE_KEYWORDS) {
73
+ if (words.some((w) => new RegExp(`\\b${escapeRegExp(w)}\\b`, 'i').test(haystack))) {
74
+ return type;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════════════
81
+ // REQUIRED ARTIFACTS PER PROJECT TYPE
82
+ // ═══════════════════════════════════════════════════════════════════════════════════
83
+
84
+ /**
85
+ * Upstream artifacts required BEFORE the first story, keyed by greenfield
86
+ * workflow. Mirrors the "Required upstream artifacts per project type" table in
87
+ * `documentation-first.md`. `feature` / `fix` / `refactor` do not require the
88
+ * upstream re-doc chain (they ride on an existing project).
89
+ * @constant
90
+ */
91
+ const REQUIRED_ARTIFACTS_BY_WORKFLOW = Object.freeze({
92
+ 'greenfield-ui': [
93
+ { id: 'brief', path: 'docs/project-brief.md' },
94
+ { id: 'prd', path: 'docs/prd.md' },
95
+ { id: 'front-end-spec', path: 'docs/front-end-spec.md' },
96
+ { id: 'architecture', path: 'docs/front-end-architecture.md' },
97
+ ],
98
+ 'greenfield-fullstack': [
99
+ { id: 'brief', path: 'docs/project-brief.md' },
100
+ { id: 'prd', path: 'docs/prd.md' },
101
+ { id: 'front-end-spec', path: 'docs/front-end-spec.md' },
102
+ { id: 'architecture', path: 'docs/fullstack-architecture.md' },
103
+ ],
104
+ 'greenfield-service': [
105
+ { id: 'brief', path: 'docs/project-brief.md' },
106
+ { id: 'prd', path: 'docs/prd.md' },
107
+ { id: 'architecture', path: 'docs/service-architecture.md' },
108
+ ],
109
+ });
110
+
111
+ /** Project types that bypass the upstream greenfield doc-chain. */
112
+ const LIGHT_TYPES = Object.freeze(['feature', 'fix', 'refactor']);
113
+
114
+ // ═══════════════════════════════════════════════════════════════════════════════════
115
+ // PROJECT MATURITY
116
+ // ═══════════════════════════════════════════════════════════════════════════════════
117
+
118
+ /** Directories whose presence (with real files) signals existing application code. */
119
+ const CODE_DIRS = Object.freeze(['src', 'app', 'packages', 'components', 'pages', 'lib']);
120
+
121
+ /**
122
+ * Assess project maturity for gate scoping. Two signals matter:
123
+ *
124
+ * - isFrameworkRepo : this IS the SINAPSE framework's own repo (package.json
125
+ * name === 'sinapse-ai'). Framework development operates
126
+ * above the story layer (Constitution Art. III exception),
127
+ * so the doc-first gate must never block it.
128
+ * - isGreenfield : the project has no substantial application code yet — a
129
+ * brand-new project. The hard doc-first gate applies HERE
130
+ * (this is the "criar um site → straight to code" case).
131
+ * Existing projects defer to the lighter story-gate.
132
+ *
133
+ * @param {string} projectRoot
134
+ * @returns {{ isFrameworkRepo: boolean, isGreenfield: boolean }}
135
+ */
136
+ function assessProjectMaturity(projectRoot) {
137
+ let isFrameworkRepo = false;
138
+ try {
139
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
140
+ if (pkg && pkg.name === 'sinapse-ai') isFrameworkRepo = true;
141
+ } catch {
142
+ // no package.json — fine
143
+ }
144
+
145
+ // Greenfield = no substantial application code yet. A lone package.json does
146
+ // NOT flip this off — otherwise scaffolding package.json first (an exempt
147
+ // file) would bypass the gate. Only populated code dirs count as "existing".
148
+ const hasCode = CODE_DIRS.some((d) => dirHasAnyFile(path.join(projectRoot, d)));
149
+ const isGreenfield = !hasCode;
150
+
151
+ return { isFrameworkRepo, isGreenfield };
152
+ }
153
+
154
+ // ═══════════════════════════════════════════════════════════════════════════════════
155
+ // MINIMAL DOC-FIRST GATE
156
+ // ═══════════════════════════════════════════════════════════════════════════════════
157
+
158
+ const VALID_STORY_STATUSES = Object.freeze(['ready', 'inprogress', 'inreview', 'done']);
159
+
160
+ /** True if a `docs/prd.md` (or sharded `docs/prd/`) exists and is non-trivial. */
161
+ function hasPrd(projectRoot) {
162
+ const file = path.join(projectRoot, 'docs', 'prd.md');
163
+ if (fileHasContent(file)) return true;
164
+ return dirHasMarkdown(path.join(projectRoot, 'docs', 'prd'));
165
+ }
166
+
167
+ /** True if at least one epic exists under `docs/epics/` or `docs/stories/epics/`. */
168
+ function hasEpic(projectRoot) {
169
+ return (
170
+ dirHasMarkdown(path.join(projectRoot, 'docs', 'epics')) ||
171
+ dirHasMarkdown(path.join(projectRoot, 'docs', 'stories', 'epics'))
172
+ );
173
+ }
174
+
175
+ /** True if at least one story under `docs/stories/` is at a >= Ready status. */
176
+ function hasReadyStory(projectRoot) {
177
+ const dir = path.join(projectRoot, 'docs', 'stories');
178
+ return walkMarkdown(dir).some((f) => storyStatusIsReady(f));
179
+ }
180
+
181
+ /**
182
+ * Parse a story file's frontmatter `status:` and decide whether it is >= Ready.
183
+ * Uses a bounded, non-greedy regex (the greedy `[\w\s]*` form historically
184
+ * captured the next line — see the story-gate memory).
185
+ * @param {string} file
186
+ * @returns {boolean}
187
+ */
188
+ function storyStatusIsReady(file) {
189
+ let content;
190
+ try {
191
+ content = fs.readFileSync(file, 'utf8');
192
+ } catch {
193
+ return false;
194
+ }
195
+ const m = content.match(/status:\s*["']?([a-z][a-z ]*?)["']?\s*(?:\r?\n|$)/i);
196
+ if (!m) return false;
197
+ const status = m[1].toLowerCase().replace(/\s+/g, '');
198
+ return VALID_STORY_STATUSES.includes(status);
199
+ }
200
+
201
+ // ═══════════════════════════════════════════════════════════════════════════════════
202
+ // MAIN RESOLVER
203
+ // ═══════════════════════════════════════════════════════════════════════════════════
204
+
205
+ /**
206
+ * Resolve the full doc-first picture for a project.
207
+ *
208
+ * @param {Object} [opts]
209
+ * @param {string} [opts.projectRoot=process.cwd()] - Project root.
210
+ * @param {string} [opts.brief] - User briefing (used to classify project type).
211
+ * @param {string} [opts.projectType] - Explicit type override (skips classification).
212
+ * @returns {{
213
+ * brief: string|null,
214
+ * projectType: string,
215
+ * classified: boolean,
216
+ * isLightType: boolean,
217
+ * workflow: string|null,
218
+ * artifacts: Array<{id:string, path:string, exists:boolean}>,
219
+ * gate: { prd:boolean, epic:boolean, readyStory:boolean, satisfied:boolean, missing:string[] }
220
+ * }}
221
+ */
222
+ function resolveDocFirstState(opts = {}) {
223
+ const projectRoot = opts.projectRoot || process.cwd();
224
+ const brief = opts.brief || null;
225
+ const classifiedType = opts.projectType || classifyProjectType(brief);
226
+ const projectType = classifiedType || 'site'; // safe default: assume a full project needs docs
227
+ const isLightType = LIGHT_TYPES.includes(projectType);
228
+
229
+ const workflow = isLightType ? null : resolveGreenfieldWorkflow(projectType);
230
+
231
+ const artifacts = (REQUIRED_ARTIFACTS_BY_WORKFLOW[workflow] || []).map((a) => ({
232
+ id: a.id,
233
+ path: a.path,
234
+ exists: fileHasContent(path.join(projectRoot, a.path)),
235
+ }));
236
+
237
+ const prd = hasPrd(projectRoot);
238
+ const epic = hasEpic(projectRoot);
239
+ const readyStory = hasReadyStory(projectRoot);
240
+
241
+ // Light types ride on an existing project: the minimal gate is "a Ready story"
242
+ // (the existing story-gate). Full projects additionally need PRD + epic.
243
+ const missing = [];
244
+ if (!readyStory) missing.push('story (status >= Ready)');
245
+ if (!isLightType) {
246
+ if (!prd) missing.push('PRD (docs/prd.md)');
247
+ if (!epic) missing.push('epic (docs/epics/)');
248
+ }
249
+ const satisfied = missing.length === 0;
250
+
251
+ return {
252
+ brief,
253
+ projectType,
254
+ classified: Boolean(classifiedType),
255
+ isLightType,
256
+ workflow,
257
+ artifacts,
258
+ gate: { prd, epic, readyStory, satisfied, missing },
259
+ maturity: assessProjectMaturity(projectRoot),
260
+ };
261
+ }
262
+
263
+ // ═══════════════════════════════════════════════════════════════════════════════════
264
+ // FS HELPERS (lean, no deps)
265
+ // ═══════════════════════════════════════════════════════════════════════════════════
266
+
267
+ function fileHasContent(file) {
268
+ try {
269
+ const stat = fs.statSync(file);
270
+ return stat.isFile() && stat.size > 0;
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ function dirHasMarkdown(dir) {
277
+ try {
278
+ return fs.readdirSync(dir).some((f) => f.toLowerCase().endsWith('.md'));
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /** True if `dir` exists and contains at least one regular file (any depth-1 entry). */
285
+ function dirHasAnyFile(dir) {
286
+ try {
287
+ return fs.readdirSync(dir, { withFileTypes: true }).some((e) => e.isFile() || e.isDirectory());
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /** Recursively collect `.md` files under a dir (bounded depth, fail-safe). */
294
+ function walkMarkdown(dir, depth = 0) {
295
+ if (depth > 4) return [];
296
+ let entries;
297
+ try {
298
+ entries = fs.readdirSync(dir, { withFileTypes: true });
299
+ } catch {
300
+ return [];
301
+ }
302
+ const out = [];
303
+ for (const e of entries) {
304
+ const full = path.join(dir, e.name);
305
+ if (e.isDirectory()) {
306
+ out.push(...walkMarkdown(full, depth + 1));
307
+ } else if (e.name.toLowerCase().endsWith('.md')) {
308
+ out.push(full);
309
+ }
310
+ }
311
+ return out;
312
+ }
313
+
314
+ module.exports = {
315
+ classifyProjectType,
316
+ resolveDocFirstState,
317
+ assessProjectMaturity,
318
+ REQUIRED_ARTIFACTS_BY_WORKFLOW,
319
+ TYPE_KEYWORDS,
320
+ // re-exported for callers that want the raw map
321
+ GREENFIELD_WORKFLOW_BY_TYPE,
322
+ };