@llm-dev-ops/agentics-cli 2.7.36 → 2.7.38

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 (95) hide show
  1. package/dist/adapters/base-adapter.d.ts.map +1 -1
  2. package/dist/adapters/base-adapter.js.map +1 -1
  3. package/dist/agents/repo-agent-runner.d.ts.map +1 -1
  4. package/dist/agents/repo-agent-runner.js +0 -2
  5. package/dist/agents/repo-agent-runner.js.map +1 -1
  6. package/dist/agents/system-prompts.d.ts.map +1 -1
  7. package/dist/agents/system-prompts.js +0 -19
  8. package/dist/agents/system-prompts.js.map +1 -1
  9. package/dist/cli/index.js +1 -1
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/commands/agents.d.ts +4 -24
  12. package/dist/commands/agents.d.ts.map +1 -1
  13. package/dist/commands/agents.js +30 -106
  14. package/dist/commands/agents.js.map +1 -1
  15. package/dist/mcp/agent-event-parser.d.ts +1 -11
  16. package/dist/mcp/agent-event-parser.d.ts.map +1 -1
  17. package/dist/mcp/agent-event-parser.js +7 -153
  18. package/dist/mcp/agent-event-parser.js.map +1 -1
  19. package/dist/mcp/mcp-server.js +0 -58
  20. package/dist/mcp/mcp-server.js.map +1 -1
  21. package/dist/pipeline/auto-chain.d.ts.map +1 -1
  22. package/dist/pipeline/auto-chain.js +27 -169
  23. package/dist/pipeline/auto-chain.js.map +1 -1
  24. package/dist/pipeline/local-fallback/phase5a-local-fallback.d.ts +21 -18
  25. package/dist/pipeline/local-fallback/phase5a-local-fallback.d.ts.map +1 -1
  26. package/dist/pipeline/local-fallback/phase5a-local-fallback.js +92 -397
  27. package/dist/pipeline/local-fallback/phase5a-local-fallback.js.map +1 -1
  28. package/dist/pipeline/phase2/phases/adr-generator.d.ts +29 -1
  29. package/dist/pipeline/phase2/phases/adr-generator.d.ts.map +1 -1
  30. package/dist/pipeline/phase2/phases/adr-generator.js +709 -1399
  31. package/dist/pipeline/phase2/phases/adr-generator.js.map +1 -1
  32. package/dist/pipeline/phase2/phases/ddd-generator.d.ts.map +1 -1
  33. package/dist/pipeline/phase2/phases/ddd-generator.js +7 -42
  34. package/dist/pipeline/phase2/phases/ddd-generator.js.map +1 -1
  35. package/dist/pipeline/phase2/phases/research-dossier.d.ts.map +1 -1
  36. package/dist/pipeline/phase2/phases/research-dossier.js +2 -33
  37. package/dist/pipeline/phase2/phases/research-dossier.js.map +1 -1
  38. package/dist/pipeline/phase2/phases/sparc-specification.d.ts.map +1 -1
  39. package/dist/pipeline/phase2/phases/sparc-specification.js +2 -27
  40. package/dist/pipeline/phase2/phases/sparc-specification.js.map +1 -1
  41. package/dist/pipeline/phase2/types.d.ts +19 -57
  42. package/dist/pipeline/phase2/types.d.ts.map +1 -1
  43. package/dist/pipeline/phase4-adrs/adr-index-extractor.d.ts +75 -0
  44. package/dist/pipeline/phase4-adrs/adr-index-extractor.d.ts.map +1 -0
  45. package/dist/pipeline/phase4-adrs/adr-index-extractor.js +200 -0
  46. package/dist/pipeline/phase4-adrs/adr-index-extractor.js.map +1 -0
  47. package/dist/pipeline/phase4-adrs/phase4-adrs-coordinator.d.ts.map +1 -1
  48. package/dist/pipeline/phase4-adrs/phase4-adrs-coordinator.js +70 -68
  49. package/dist/pipeline/phase4-adrs/phase4-adrs-coordinator.js.map +1 -1
  50. package/dist/pipeline/phase7/deliverables-registry.js +1 -1
  51. package/dist/pipeline/phase7/deliverables-registry.js.map +1 -1
  52. package/dist/pipeline/phases/adr-ddd-generator.d.ts.map +1 -1
  53. package/dist/pipeline/phases/adr-ddd-generator.js +48 -2
  54. package/dist/pipeline/phases/adr-ddd-generator.js.map +1 -1
  55. package/dist/pipeline/phases/prompt-generator.js +191 -80
  56. package/dist/pipeline/phases/prompt-generator.js.map +1 -1
  57. package/dist/pipeline/ruflo-phase-executor.d.ts +2 -5
  58. package/dist/pipeline/ruflo-phase-executor.d.ts.map +1 -1
  59. package/dist/pipeline/ruflo-phase-executor.js +72 -23
  60. package/dist/pipeline/ruflo-phase-executor.js.map +1 -1
  61. package/dist/pipeline/types.d.ts +14 -1
  62. package/dist/pipeline/types.d.ts.map +1 -1
  63. package/dist/routing/domain-boundary.d.ts +4 -20
  64. package/dist/routing/domain-boundary.d.ts.map +1 -1
  65. package/dist/routing/domain-boundary.js +6 -81
  66. package/dist/routing/domain-boundary.js.map +1 -1
  67. package/dist/routing/graph-router.d.ts.map +1 -1
  68. package/dist/routing/graph-router.js +0 -22
  69. package/dist/routing/graph-router.js.map +1 -1
  70. package/dist/synthesis/ask-artifact-writer.d.ts +1 -1
  71. package/dist/synthesis/ask-artifact-writer.d.ts.map +1 -1
  72. package/dist/synthesis/ask-artifact-writer.js +9 -9
  73. package/dist/synthesis/ask-artifact-writer.js.map +1 -1
  74. package/dist/synthesis/simulation-artifact-generator.d.ts +1 -27
  75. package/dist/synthesis/simulation-artifact-generator.d.ts.map +1 -1
  76. package/dist/synthesis/simulation-artifact-generator.js +38 -128
  77. package/dist/synthesis/simulation-artifact-generator.js.map +1 -1
  78. package/docs/ecosystem.graph.json +15 -65
  79. package/package.json +1 -1
  80. package/dist/cli/ui/heartbeat.d.ts +0 -88
  81. package/dist/cli/ui/heartbeat.d.ts.map +0 -1
  82. package/dist/cli/ui/heartbeat.js +0 -158
  83. package/dist/cli/ui/heartbeat.js.map +0 -1
  84. package/dist/config/qe-gating.d.ts +0 -81
  85. package/dist/config/qe-gating.d.ts.map +0 -1
  86. package/dist/config/qe-gating.js +0 -138
  87. package/dist/config/qe-gating.js.map +0 -1
  88. package/dist/pipeline/phase5-build/qe-gating-executor.d.ts +0 -73
  89. package/dist/pipeline/phase5-build/qe-gating-executor.d.ts.map +0 -1
  90. package/dist/pipeline/phase5-build/qe-gating-executor.js +0 -134
  91. package/dist/pipeline/phase5-build/qe-gating-executor.js.map +0 -1
  92. package/dist/synthesis/agent-fleet-decomposer.d.ts +0 -124
  93. package/dist/synthesis/agent-fleet-decomposer.d.ts.map +0 -1
  94. package/dist/synthesis/agent-fleet-decomposer.js +0 -696
  95. package/dist/synthesis/agent-fleet-decomposer.js.map +0 -1
@@ -10,7 +10,9 @@
10
10
  */
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
- import { execFileSync } from 'node:child_process';
13
+ import { execFileSync, spawn } from 'node:child_process';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { fileURLToPath } from 'node:url';
14
16
  import { createSpan, endSpan, emitSpan } from '../telemetry.js';
15
17
  import { detectTechnologyStack, writeTechStack, formatERPContextForPrompt } from './tech-stack-detector.js';
16
18
  const DIR_MODE = 0o700;
@@ -70,176 +72,54 @@ function findDossierRefs(dossier, keywords) {
70
72
  // ============================================================================
71
73
  // ADR Markdown Renderer
72
74
  // ============================================================================
73
- /**
74
- * Render a Phase2ADRRecord as a markdown document.
75
- *
76
- * ADR-PIPELINE-101 — when the LLM-generated record carries the rich-content
77
- * fields (architectureDiagram, components, performanceTargets, references,
78
- * appendices, implementationStatus), the renderer interleaves them into a
79
- * structure that mirrors `docs/good_ADR_example.md`. When those fields are
80
- * absent (deterministic fallback path), it produces the legacy thin format
81
- * for backwards compatibility.
82
- */
83
75
  function renderADRMarkdown(adr) {
76
+ // ADR-PIPELINE-100 §D1: when the LLM emitted markdown directly, write it
77
+ // verbatim. Structured fields are best-effort metadata, not the source of
78
+ // truth — re-rendering from them would lose diagrams, code samples, tables.
79
+ if (adr.rawMarkdown && adr.rawMarkdown.trim().length > 0) {
80
+ return adr.rawMarkdown.endsWith('\n') ? adr.rawMarkdown : adr.rawMarkdown + '\n';
81
+ }
84
82
  const lines = [];
85
83
  lines.push(`# ${adr.id}: ${adr.title}`);
86
84
  lines.push('');
87
85
  lines.push(`**Status:** ${adr.status}`);
88
86
  lines.push(`**Date:** ${adr.date}`);
89
- if (adr.authors && adr.authors.length > 0) {
90
- lines.push(`**Authors:** ${adr.authors.join(', ')}`);
91
- }
92
- if (adr.deciders && adr.deciders.length > 0) {
93
- lines.push(`**Deciders:** ${adr.deciders.join(', ')}`);
94
- }
95
- if (adr.supersedes) {
96
- lines.push('');
97
- lines.push(`**Note:** This ADR supersedes ${adr.supersedes}.`);
98
- }
99
- lines.push('');
100
- lines.push('---');
101
87
  lines.push('');
102
- // ── Context ─────────────────────────────────────────────────────────
103
88
  lines.push('## Context');
104
- lines.push('');
105
89
  lines.push(adr.context);
106
90
  lines.push('');
107
- // ── Decision ────────────────────────────────────────────────────────
108
91
  lines.push('## Decision');
109
- lines.push('');
110
92
  lines.push(adr.decision);
111
93
  lines.push('');
112
- // ── Architecture diagram (if provided) ──────────────────────────────
113
- if (adr.architectureDiagram && adr.architectureDiagram.trim()) {
114
- lines.push('### Architecture');
115
- lines.push('');
116
- lines.push('```');
117
- lines.push(adr.architectureDiagram.trim());
118
- lines.push('```');
119
- lines.push('');
120
- }
121
- // ── Key Components ──────────────────────────────────────────────────
122
- if (adr.components && adr.components.length > 0) {
123
- lines.push('## Key Components');
124
- lines.push('');
125
- let idx = 0;
126
- for (const comp of adr.components) {
127
- idx++;
128
- lines.push(`### ${idx}. ${comp.name}`);
129
- lines.push('');
130
- lines.push(comp.description);
131
- lines.push('');
132
- if (comp.codeExample && comp.codeExample.code.trim()) {
133
- lines.push('```' + (comp.codeExample.language || ''));
134
- lines.push(comp.codeExample.code.trim());
135
- lines.push('```');
136
- lines.push('');
137
- }
138
- if (comp.configTable && comp.configTable.rows.length > 0) {
139
- lines.push(renderTable(comp.configTable.headers, comp.configTable.rows));
140
- lines.push('');
141
- }
142
- if (comp.performanceCharacteristics && comp.performanceCharacteristics.length > 0) {
143
- lines.push('**Performance characteristics:**');
144
- lines.push('');
145
- for (const p of comp.performanceCharacteristics)
146
- lines.push(`- ${p}`);
147
- lines.push('');
148
- }
149
- if (comp.securityNotes && comp.securityNotes.length > 0) {
150
- lines.push('**Security guarantees:**');
151
- lines.push('');
152
- for (const s of comp.securityNotes)
153
- lines.push(`- ${s}`);
154
- lines.push('');
155
- }
156
- }
157
- }
158
- // ── Performance Targets (top-level table) ───────────────────────────
159
- if (adr.performanceTargets && adr.performanceTargets.rows.length > 0) {
160
- lines.push('## Performance Targets');
161
- lines.push('');
162
- lines.push(renderTable(adr.performanceTargets.headers, adr.performanceTargets.rows));
163
- lines.push('');
164
- }
165
- // ── Alternatives ────────────────────────────────────────────────────
166
94
  if (adr.alternatives.length > 0) {
167
95
  lines.push('## Alternatives Considered');
168
- lines.push('');
169
96
  for (const alt of adr.alternatives) {
170
97
  const status = alt.rejected ? '(rejected)' : '(selected)';
171
98
  lines.push(`- **${alt.option}** ${status}: ${alt.rationale}`);
172
99
  }
173
100
  lines.push('');
174
101
  }
175
- // ── Consequences ────────────────────────────────────────────────────
176
102
  if (adr.consequences.length > 0) {
177
103
  lines.push('## Consequences');
178
- lines.push('');
179
104
  for (const c of adr.consequences) {
180
105
  const icon = c.type === 'positive' ? '+' : c.type === 'negative' ? '-' : '~';
181
106
  lines.push(`- [${icon}] ${c.description}`);
182
107
  }
183
108
  lines.push('');
184
109
  }
185
- // ── Implementation Status ───────────────────────────────────────────
186
- if (adr.implementationStatus && adr.implementationStatus.rows.length > 0) {
187
- lines.push('## Implementation Status');
188
- lines.push('');
189
- lines.push(renderTable(adr.implementationStatus.headers, adr.implementationStatus.rows));
190
- lines.push('');
191
- }
192
- // ── References (numbered) ───────────────────────────────────────────
193
- if (adr.references && adr.references.length > 0) {
194
- lines.push('## References');
195
- lines.push('');
196
- let r = 0;
197
- for (const ref of adr.references) {
198
- r++;
199
- lines.push(`${r}. ${ref}`);
200
- }
201
- lines.push('');
202
- }
203
- // ── Dossier item refs (kept as a separate section so they don't bury
204
- // the formal references) ───────────────────────────────────────────
205
110
  if (adr.researchDossierItemRefs.length > 0) {
206
- lines.push('## Research Dossier Source Items');
207
- lines.push('');
208
- lines.push(adr.researchDossierItemRefs.map(r => `\`${r}\``).join(', '));
111
+ lines.push('## References');
112
+ lines.push(`Dossier items: ${adr.researchDossierItemRefs.join(', ')}`);
209
113
  lines.push('');
210
114
  }
211
- // ── Appendices ──────────────────────────────────────────────────────
212
- if (adr.appendices && adr.appendices.length > 0) {
213
- let letterCode = 'A'.charCodeAt(0);
214
- for (const apx of adr.appendices) {
215
- lines.push(`## Appendix ${String.fromCharCode(letterCode)}: ${apx.title}`);
216
- lines.push('');
217
- lines.push(apx.body);
218
- lines.push('');
219
- letterCode++;
220
- }
221
- }
222
115
  // ADR-PIPELINE-018: Include simulation lineage when present
223
116
  if (adr.simulationId) {
224
117
  lines.push('## Lineage');
225
- lines.push('');
226
- lines.push(`Originating simulation: \`${adr.simulationId}\``);
118
+ lines.push(`Originating simulation: ${adr.simulationId}`);
227
119
  lines.push('');
228
120
  }
229
121
  return lines.join('\n');
230
122
  }
231
- /** Render a markdown table — header row + separator + data rows. */
232
- function renderTable(headers, rows) {
233
- const lines = [];
234
- lines.push(`| ${headers.join(' | ')} |`);
235
- lines.push(`|${headers.map(() => '---').join('|')}|`);
236
- for (const row of rows) {
237
- // Pad/truncate to header length so the table stays valid.
238
- const padded = headers.map((_, i) => row[i] ?? '');
239
- lines.push(`| ${padded.join(' | ')} |`);
240
- }
241
- return lines.join('\n');
242
- }
243
123
  // ============================================================================
244
124
  // ADR Builder — derived from SPARC + dossier
245
125
  // ============================================================================
@@ -250,994 +130,42 @@ function makeCons(description, type) {
250
130
  return { description, type };
251
131
  }
252
132
  /**
253
- * Normalize a loosely-typed SPARC object (e.g. loaded from disk with unknown
254
- * key shapes) into the arrays that ADR generation needs.
133
+ * Generate ADRs for a project via the LLM transports.
134
+ *
135
+ * ADR-PIPELINE-100 §D5: there is no silent SPARC-template fallback. When
136
+ * every LLM transport fails this returns a single loud-failure ADR record
137
+ * that the renderer writes as `ADR-FAIL-001.md`.
138
+ *
139
+ * Callers that want to skip the LLM and emit a failure ADR directly (the
140
+ * auto-chain "ensure ADRs exist" guarantee paths) should call
141
+ * `buildLoudFailureADR` directly rather than passing a flag here.
255
142
  */
256
- function normalizeSPARCForADR(sparc) {
257
- // Services: may be on architecture.services or architecture.components or top-level services
258
- const raw = sparc;
259
- const arch = (raw.architecture ?? {});
260
- let services = [];
261
- const candidateServices = arch.services ?? arch.components ?? arch.modules ?? raw.services;
262
- if (Array.isArray(candidateServices)) {
263
- services = candidateServices.map((s, i) => ({
264
- id: String(s.id ?? `SVC-${String(i + 1).padStart(3, '0')}`),
265
- name: String(s.name ?? s.label ?? `Service${i + 1}`),
266
- responsibility: String(s.responsibility ?? s.description ?? s.purpose ?? ''),
267
- api: Array.isArray(s.api) ? s.api.map(String) : Array.isArray(s.endpoints) ? s.endpoints.map(String) : [],
268
- dependencies: Array.isArray(s.dependencies) ? s.dependencies.map(String) : Array.isArray(s.deps) ? s.deps.map(String) : [],
269
- dossierItemRefs: Array.isArray(s.dossierItemRefs) ? s.dossierItemRefs.map(String) : [],
270
- }));
271
- }
272
- // Security: may be array of objects or array of strings
273
- const ref = (raw.refinement ?? {});
274
- let securityItems = [];
275
- const rawSec = ref.securityConsiderations ?? ref.security ?? [];
276
- if (Array.isArray(rawSec)) {
277
- securityItems = rawSec.map((item, i) => {
278
- if (typeof item === 'string')
279
- return { id: `sec-${i + 1}`, text: item, dossierItemRefs: [] };
280
- const obj = item;
281
- return { id: String(obj.id ?? `sec-${i + 1}`), text: String(obj.text ?? obj.description ?? obj.name ?? ''), dossierItemRefs: [] };
282
- });
283
- }
284
- // Performance: may be array of objects or array of strings
285
- let perfItems = [];
286
- const rawPerf = ref.performanceTargets ?? ref.performance ?? [];
287
- if (Array.isArray(rawPerf)) {
288
- perfItems = rawPerf.map((item, i) => {
289
- if (typeof item === 'string')
290
- return { id: `perf-${i + 1}`, text: item, dossierItemRefs: [] };
291
- const obj = item;
292
- return { id: String(obj.id ?? `perf-${i + 1}`), text: String(obj.text ?? obj.description ?? obj.target ?? ''), dossierItemRefs: [] };
293
- });
294
- }
295
- const comp = (raw.completion ?? {});
296
- const integrationPlan = typeof comp.integrationPlan === 'string' ? comp.integrationPlan : '';
297
- return { services, securityItems, perfItems, integrationPlan };
298
- }
299
- function detectAlgorithmicDecisions(query, domain) {
300
- const decisions = [];
301
- const q = query;
302
- // ── 1. Weighted / Multi-Objective Scoring Algorithm ──
303
- const hasScoring = /(?:weight|score|rank|multi.?(?:objective|criteria|factor|dimension)|normali[sz]|compar(?:e|ison).*(?:score|rank|metric))/i.test(q);
304
- const hasCostSpeedEmissions = /(?:cost|speed|emission|carbon|sustainab|environmental)/i.test(q) && /(?:weight|balanc|tradeoff|trade.?off|priori)/i.test(q);
305
- if (hasScoring || hasCostSpeedEmissions) {
306
- decisions.push({
307
- title: `${domain.primary} Scoring Algorithm: weighted multi-objective normalization with configurable dimensions`,
308
- context: `${domain.primary} requires comparing options across multiple competing dimensions (e.g. cost, speed, emissions). ` +
309
- `Each dimension operates on a different scale and unit. Stakeholders need to adjust relative importance of dimensions without code changes. ` +
310
- `The scoring algorithm embeds a non-obvious design choice: how to normalize disparate units into comparable scores.`,
311
- decision: `Use min-max normalization per dimension to map all values to [0, 100], then apply configurable weights that sum to 1.0. ` +
312
- `Round only the final composite score, not individual dimension scores (avoids compounding rounding error). ` +
313
- `Handle edge case where all options have identical values for a dimension (return score 100 for that dimension). ` +
314
- `Expose weights as configuration, not hardcoded constants, so domain experts can tune without code changes. ` +
315
- `Include weights used in every output for reproducibility and audit.`,
316
- alternatives: [
317
- makeAlt('Min-max normalization with configurable weights', 'Well-established technique; weights are intuitive for domain experts; handles disparate units', false),
318
- makeAlt('Z-score normalization', 'Better for normally distributed data but weights are less intuitive for business users', true),
319
- makeAlt('Rank-based scoring', 'Loses magnitude information — a 10x cost difference scores the same as 1.1x', true),
320
- makeAlt('Hardcoded scoring rules', 'Requires developer for every threshold change; not auditable', true),
321
- ],
322
- consequences: [
323
- makeCons('Domain experts can adjust priorities (cost vs speed vs emissions) without code changes', 'positive'),
324
- makeCons('Scores are deterministic and reproducible given the same inputs and weights', 'positive'),
325
- makeCons('Min-max normalization is sensitive to outliers — a single extreme value compresses the rest', 'negative'),
326
- makeCons('Weight validation (sum to 1.0) must be enforced at both API boundary and adapter level', 'negative'),
327
- ],
328
- keywords: ['scoring', 'weight', 'normalization', 'multi-objective', 'ranking'],
329
- });
330
- }
331
- // ── 2. Option Filtering / Selection Strategy ──
332
- const hasOptionFiltering = /(?:route|routing|modal|intermodal|multi.?modal|transport.*mode|shipping.*mode|freight|upgrade.*option|option.*filter|viab|feasib|eligible|candidate)/i.test(q);
333
- const hasOptionSplit = /(?:split|distribut|allocat|percentage|ratio|mix|combination|portfolio)/i.test(q)
334
- && /(?:option|mode|modal|upgrade|investment|scenario)/i.test(q);
335
- if (hasOptionFiltering || hasOptionSplit) {
336
- decisions.push({
337
- title: `${domain.primary} Option Selection: viability filtering with configurable thresholds and option categorization`,
338
- context: `${domain.primary} involves selecting between multiple options or strategies. ` +
339
- `Option viability depends on domain-specific constraints (cost, capacity, timing, regulatory requirements). ` +
340
- `Combined or hybrid options require defining allocation ratios. ` +
341
- `Viability thresholds embed non-obvious business knowledge and vary by context (region, property type, asset class).`,
342
- decision: `Filter viable options by domain-specific thresholds (e.g. budget limits, capacity constraints, regulatory requirements). ` +
343
- `Make all thresholds configurable via environment variables with sensible defaults. ` +
344
- `For combined strategies, define allocation ratios as configuration (not hardcoded). ` +
345
- `Generate recommendations only for viable options — do not score options that fail viability checks. ` +
346
- `Document option characteristics (cost, impact, timeline, constraints) as a typed configuration object.`,
347
- alternatives: [
348
- makeAlt('Threshold-based viability filtering with configurable parameters', 'Fast elimination of non-viable options; thresholds tunable per context', false),
349
- makeAlt('Score all options regardless of viability', 'Wastes computation; produces misleading recommendations for infeasible options', true),
350
- makeAlt('Hardcoded selection rules', 'Cannot adapt to regional or contextual differences', true),
351
- ],
352
- consequences: [
353
- makeCons('Different deployments can customize thresholds without code changes', 'positive'),
354
- makeCons('Option allocation ratios are transparent and auditable', 'positive'),
355
- makeCons('Threshold values require domain expertise to set correctly — wrong defaults produce wrong recommendations', 'negative'),
356
- ],
357
- keywords: ['option', 'filter', 'viability', 'threshold', 'selection', 'upgrade', 'candidate'],
358
- });
359
- }
360
- // ── 3. Emissions / Environmental Impact Factor Sourcing ──
361
- const hasEmissions = /(?:emission|carbon|co2|greenhouse|ghg|sustainab|environmental.*factor|emission.*factor|energy.*efficien|eui|energy.*usage)/i.test(q);
362
- if (hasEmissions) {
363
- // Detect the domain type to use appropriate examples
364
- const isRealEstate = /(?:building|property|propert|real\s*estate|hvac|insulation|lighting|tenant|facility|eui)/i.test(q);
365
- const emissionsUnit = isRealEstate ? 'kgCO2e/sqft/year per building type and climate zone' : 'gCO2 per unit of activity';
366
- const emissionsExamples = isRealEstate
367
- ? 'building type, climate zone, age, HVAC system type, and insulation rating'
368
- : 'region, equipment type, operational parameters, and fuel source';
369
- const emissionsFrameworks = isRealEstate
370
- ? 'ENERGY STAR Portfolio Manager, CRREM, GRESB, EPA eGRID'
371
- : 'GLEC Framework, EPA, DEFRA, GHG Protocol';
372
- decisions.push({
373
- title: `${domain.primary} Impact Factors: sourcing strategy with regional variation and customization`,
374
- context: `${domain.primary} calculates environmental impact using emissions/energy factors (e.g. ${emissionsUnit}). ` +
375
- `These factors vary significantly by ${emissionsExamples}. ` +
376
- `Using global averages produces inaccurate results for specific assets or regions. ` +
377
- `The sourcing strategy for these factors is a non-obvious design choice with regulatory and reporting implications.`,
378
- decision: `Provide default impact factors based on established sources (e.g. ${emissionsFrameworks}). ` +
379
- `Allow per-request override of factors so users can supply asset-specific or audited values. ` +
380
- `Store the factors used in every calculation output for auditability. ` +
381
- `Flag when default (benchmark) factors are used vs. user-supplied measured values in the output metadata. ` +
382
- `Support regional factor profiles as named configurations (e.g. "EU", "North America", "APAC").`,
383
- alternatives: [
384
- makeAlt('Configurable factors with defaults from established frameworks', 'Balances accuracy with usability; supports regulatory audit', false),
385
- makeAlt('Hardcoded global averages only', 'Inaccurate for specific assets; cannot meet regional regulatory requirements', true),
386
- makeAlt('Real-time factor API integration', 'Adds external dependency and latency; factors change slowly (annually)', true),
387
- ],
388
- consequences: [
389
- makeCons('Calculations are auditable — every output shows which factors were used and their source', 'positive'),
390
- makeCons('Regional deployments can use jurisdiction-specific factors', 'positive'),
391
- makeCons('Default factors must be maintained and updated when source frameworks publish new versions', 'negative'),
392
- ],
393
- keywords: ['emissions', 'carbon', 'environmental', 'factor', 'sustainability', 'energy', 'EUI'],
394
- });
395
- }
396
- // ── 4. Priority / Constraint Threshold Model ──
397
- const hasPriority = /(?:sla|service.?level|tolerance|priority|urgency|deadline|lead.?time|delivery.*time|transit.*time|timeline|disruption|impact.*level)/i.test(q);
398
- const hasThresholds = /(?:threshold|viable|feasib|acceptable|constraint|buffer|slack)/i.test(q);
399
- if (hasPriority || (hasThresholds && hasOptionFiltering)) {
400
- const isRealEstate = /(?:building|property|propert|real\s*estate|tenant|facility)/i.test(q);
401
- const priorityExamples = isRealEstate
402
- ? 'critical (safety/compliance), high (major efficiency gain), standard (planned improvement), low (cosmetic/minor)'
403
- : 'express: 0 days tolerance, standard: 1 day, economy: 2 days';
404
- const constraintType = isRealEstate
405
- ? 'tenant disruption level, implementation timeline, and budget constraints'
406
- : 'time constraints and service level requirements';
407
- decisions.push({
408
- title: `${domain.primary} Priority Model: constraint-based viability thresholds with configurable levels`,
409
- context: `${domain.primary} must filter and prioritize options based on ${constraintType}. Different priority levels ` +
410
- `(e.g. ${priorityExamples}) have different constraint tolerances. ` +
411
- `The threshold values embed domain knowledge about acceptable tradeoffs per priority class.`,
412
- decision: `Define priority levels as a typed enum with associated constraint tolerance values. ` +
413
- `Filter options by checking if estimated impact ≤ acceptable threshold for the given priority. ` +
414
- `Make tolerance values configurable per priority level via environment variables. ` +
415
- `When all options are filtered out (no viable options), return an explicit "no viable options" response rather than an empty array or 500 error.`,
416
- alternatives: [
417
- makeAlt('Priority-based constraint thresholds with configurable tolerances', 'Intuitive model; domain experts can adjust per-priority tolerances', false),
418
- makeAlt('Single global threshold', 'Cannot differentiate high-priority from low-priority — one threshold for all', true),
419
- makeAlt('No priority filtering (rank all options)', 'Returns options that violate constraints; wastes decision-maker time', true),
420
- ],
421
- consequences: [
422
- makeCons('High-priority items never recommend options that violate critical constraints', 'positive'),
423
- makeCons('Empty-result edge case is handled explicitly, preventing runtime crashes', 'positive'),
424
- makeCons('Priority-tolerance mapping requires business sign-off; wrong values produce wrong recommendations', 'negative'),
425
- ],
426
- keywords: ['priority', 'SLA', 'tolerance', 'threshold', 'constraint', 'disruption', 'timeline'],
427
- });
428
- }
429
- // ── 5. Distance / Cost Estimation Strategy ──
430
- const hasDistanceEstimation = /(?:distance|haversine|geodesic|great.?circle|route.*length|estimat.*distance)/i.test(q);
431
- const hasHardcodedData = /(?:hardcode|lookup|table|fallback.*default|unknown.*route|default.*distance)/i.test(q);
432
- if (hasDistanceEstimation || (hasOptionFiltering && hasHardcodedData)) {
433
- decisions.push({
434
- title: `${domain.primary} Distance Estimation: strategy for unknown routes with fallback handling`,
435
- context: `${domain.primary} needs point-to-point distances for scoring. A lookup table covers known high-volume corridors, ` +
436
- `but unknown origin-destination pairs fall back to a default value. A naive default (e.g. 5000km) materially affects scoring accuracy — ` +
437
- `short routes scored as long routes get wrong mode recommendations. The fallback strategy is a critical design choice.`,
438
- decision: `Use haversine (great-circle) calculation as the primary fallback for unknown routes instead of a fixed default distance. ` +
439
- `Maintain a lookup table for known high-volume corridors where actual road/rail distances differ significantly from great-circle. ` +
440
- `Flag outputs that used estimated (non-lookup) distances so decision-makers know the accuracy level. ` +
441
- `Apply a mode-specific distance multiplier to haversine results (e.g. road: 1.3x, rail: 1.2x, sea: 1.1x) to approximate actual route distances.`,
442
- alternatives: [
443
- makeAlt('Haversine fallback with corridor lookup table', 'Reasonable accuracy for unknown routes; exact for known corridors', false),
444
- makeAlt('Fixed default distance for unknowns', 'Produces materially wrong scores — a 200km route scored as 5000km recommends sea freight', true),
445
- makeAlt('External routing API (Google Maps, HERE)', 'Adds latency, cost, and external dependency for every comparison', true),
446
- ],
447
- consequences: [
448
- makeCons('Unknown routes get reasonable distance estimates instead of a wildly wrong default', 'positive'),
449
- makeCons('Output transparency — decision-makers see whether distance was exact or estimated', 'positive'),
450
- makeCons('Haversine underestimates road distance by ~20-40% for complex terrain; multiplier is an approximation', 'negative'),
451
- ],
452
- keywords: ['distance', 'haversine', 'estimation', 'fallback', 'route', 'corridor'],
453
- });
454
- }
455
- // ── 6. Comparison Immutability / Audit Chain ──
456
- const hasComparison = /(?:compar(?:e|ison)|scenario.*(?:save|store|persist)|immutab|audit.*chain|tamper)/i.test(q);
457
- const hasAuditTrail = /(?:audit|lineage|hash|reproducib|traceab)/i.test(q);
458
- if (hasComparison && hasAuditTrail) {
459
- decisions.push({
460
- title: `${domain.primary} Scenario Comparison: immutability model with content-hash audit chain`,
461
- context: `${domain.primary} produces scenario comparisons that inform business decisions. Once a comparison is created, ` +
462
- `it must not change — decision-makers need to trust that the comparison they approved is the same one that was generated. ` +
463
- `The immutability model and audit chain design embed governance requirements.`,
464
- decision: `Scenario comparisons are write-once: created via POST, readable via GET, never updated or deleted. ` +
465
- `Each comparison includes a content hash (SHA-256 of all inputs + weights + results) for tamper detection. ` +
466
- `Link each comparison to its originating simulation ID for full lineage tracing. ` +
467
- `Wrap comparison persistence in a database transaction — if the INSERT fails, the caller gets an error, not a phantom comparison. ` +
468
- `Add a database index on (shipment_id, created_at) for efficient listing queries.`,
469
- alternatives: [
470
- makeAlt('Write-once with content hash and simulation lineage', 'Meets audit and compliance requirements; tamper-detectable', false),
471
- makeAlt('Mutable comparisons', 'Cannot prove which version was approved — audit gap', true),
472
- makeAlt('In-memory only comparisons', 'Lost on process restart; no audit trail', true),
473
- ],
474
- consequences: [
475
- makeCons('Every comparison is traceable back to its simulation and forward to any decisions made from it', 'positive'),
476
- makeCons('Content hash enables tamper detection without blockchain overhead', 'positive'),
477
- makeCons('Write-once storage grows continuously — requires archival strategy for old comparisons', 'negative'),
478
- ],
479
- keywords: ['comparison', 'immutability', 'audit', 'hash', 'lineage', 'tamper'],
480
- });
481
- }
482
- return decisions;
483
- }
484
- function buildADRsFromSPARC(sparc, dossier, scenarioQuery) {
485
- const adrs = [];
486
- let idx = 1;
487
- const today = new Date().toISOString().slice(0, 10);
488
- const q = scenarioQuery ?? '';
489
- const { services, securityItems, perfItems, integrationPlan } = normalizeSPARCForADR(sparc);
490
- // ── Extract domain context from the query ──────────────────────────────
491
- const domain = extractDomainContext(q);
492
- const techStack = extractTechFromQuery(q);
493
- // ======================================================================
494
- // SPARC §S — Specification: Domain Model ADR
495
- // What entities exist, how they relate, what invariants constrain them
496
- // ======================================================================
497
- if (services.length > 0) {
498
- const entityNames = services.slice(0, 6).map(s => s.name);
499
- const depsAll = services.flatMap(s => s.dependencies).filter(Boolean);
500
- const uniqueDeps = [...new Set(depsAll)];
501
- adrs.push({
502
- id: `ADR-P2-${pad3(idx++)}`,
503
- title: `Domain Model: ${domain.primary} entity graph with ${entityNames.length} bounded contexts`,
504
- status: 'proposed',
505
- date: today,
506
- context: `The ${domain.primary} domain decomposes into ${entityNames.length} bounded contexts: ${entityNames.join(', ')}. ` +
507
- `${uniqueDeps.length > 0 ? 'Cross-context dependencies: ' + uniqueDeps.slice(0, 5).join(', ') + '. ' : ''}` +
508
- `${domain.stakeholders ? 'Stakeholders: ' + domain.stakeholders + '. ' : ''}` +
509
- `Each context must own its data and enforce its own invariants.`,
510
- decision: `Model ${domain.primary} as ${entityNames.length} bounded contexts with explicit aggregate roots. ` +
511
- `${entityNames.slice(0, 3).map(n => `"${n}" owns its entities and exposes a domain API`).join('; ')}. ` +
512
- `Cross-context communication via domain events (not shared database). ` +
513
- `${domain.erp ? 'Anti-corruption layer translates between ' + domain.erp + ' wire format and domain types.' : ''}`,
514
- alternatives: [
515
- makeAlt(`${entityNames.length} bounded contexts with domain events`, 'Each context evolves independently; matches ' + domain.primary + ' organizational structure', false),
516
- makeAlt('Shared domain model', 'Faster initial development but all contexts coupled — any schema change ripples everywhere', true),
517
- makeAlt('CRUD-only without domain model', 'Simpler but business rules scattered across layers, untestable', true),
518
- ],
519
- consequences: [
520
- makeCons(`Each ${domain.primary} context can be developed, tested, and deployed independently`, 'positive'),
521
- makeCons('Domain events provide audit trail of all cross-context interactions', 'positive'),
522
- makeCons('Requires upfront domain modeling investment before implementation', 'negative'),
523
- makeCons(`Cross-context queries (e.g. ${entityNames.length > 1 ? entityNames[0] + ' → ' + entityNames[1] : 'aggregation'}) require API composition, not JOINs`, 'negative'),
524
- ],
525
- sparcSectionRefs: ['specification.requirements', 'architecture.services'],
526
- researchDossierItemRefs: findDossierRefs(dossier, entityNames),
527
- });
528
- }
529
- // ======================================================================
530
- // SPARC §P — Pseudocode: Simulation / Optimization / Core Algorithm ADR
531
- // HOW the core computation works — not generic, specific to the domain
532
- // ======================================================================
533
- const hasSimulation = /simulat|scenario|what.?if|forecast|predict|model(?:ing|ed|s)/i.test(q);
534
- const hasOptimization = /optimiz|minimize|maximize|tradeoff|trade.?off|pareto|quantif/i.test(q);
535
- const hasGraphModeling = /graph|network|dependenc|flow|topolog|adjacen/i.test(q);
536
- if (hasSimulation || hasOptimization || hasGraphModeling) {
537
- const algoType = hasOptimization ? 'multi-objective optimization'
538
- : hasGraphModeling ? 'graph-based dependency modeling'
539
- : 'scenario simulation';
540
- const rustNote = techStack.includes('Rust')
541
- ? `Implement the compute-intensive ${algoType} engine in Rust for performance (graph traversal, constraint solving, Monte Carlo). TypeScript orchestrates scenario configuration, result aggregation, and API exposure.`
542
- : `Implement ${algoType} engine with clear separation between scenario configuration (inputs), computation (engine), and result presentation (outputs).`;
543
- const simOutputs = hasOptimization
544
- ? 'Pareto frontier across competing objectives (cost, risk, emissions, complexity). Each point on the frontier is a decision-grade scenario with full traceoff breakdown.'
545
- : 'Ranked scenario comparison with metrics per dimension. Each scenario includes confidence intervals, sensitivity analysis, and assumption documentation.';
546
- adrs.push({
547
- id: `ADR-P2-${pad3(idx++)}`,
548
- title: `${domain.primary} ${hasOptimization ? 'Optimization' : 'Simulation'} Engine: ${algoType} with immutable inputs/outputs`,
549
- status: 'proposed',
550
- date: today,
551
- context: `The system must ${hasOptimization ? 'optimize across competing objectives' : 'simulate multiple strategies'} for ${domain.primary}. ` +
552
- `Decision-makers need structured, comparative outputs — not automated execution. ` +
553
- `${hasGraphModeling ? 'The domain involves network/graph structures with dependencies that affect propagation of changes. ' : ''}` +
554
- `All simulation inputs must be immutable; results must be reproducible and auditable.`,
555
- decision: `${rustNote} ` +
556
- `Simulation accepts immutable scenario parameters, runs computation, and produces: ${simOutputs} ` +
557
- `Results carry a cryptographic hash of inputs for reproducibility verification. ` +
558
- `No execution occurs from simulation — results are decision-grade outputs for human review.`,
559
- alternatives: [
560
- makeAlt(`Dedicated ${algoType} engine with immutable I/O`, 'Auditable, reproducible, supports regulatory review', false),
561
- makeAlt('Spreadsheet-based modeling', 'Insufficient for multi-dimensional analysis at enterprise scale', true),
562
- makeAlt('Auto-executing optimizer', 'Violates human-in-the-loop governance requirement', true),
563
- ],
564
- consequences: [
565
- makeCons('Every simulation result is reproducible — same inputs always produce same outputs', 'positive'),
566
- makeCons('Stakeholders compare scenarios side-by-side before committing resources', 'positive'),
567
- makeCons(techStack.includes('Rust') ? 'Requires Rust/TypeScript FFI boundary and dual-language build pipeline' : 'Compute-intensive scenarios may require dedicated infrastructure', 'negative'),
568
- makeCons('Scenario parameter space must be bounded to prevent combinatorial explosion', 'negative'),
569
- ],
570
- sparcSectionRefs: ['pseudocode.modules', 'architecture.dataFlow'],
571
- researchDossierItemRefs: findDossierRefs(dossier, ['simulation', 'optimization', 'scenario', 'model', 'graph']),
572
- });
573
- }
574
- // ======================================================================
575
- // SPARC §P+ — Domain-Specific Algorithmic Decision ADRs
576
- // Detect algorithmic patterns in the query that warrant dedicated ADRs
577
- // (ADR-PIPELINE-016: each non-obvious design choice gets its own ADR)
578
- // ======================================================================
579
- const algorithmPatterns = detectAlgorithmicDecisions(q, domain);
580
- for (const algo of algorithmPatterns) {
581
- adrs.push({
582
- id: `ADR-P2-${pad3(idx++)}`,
583
- title: algo.title,
584
- status: 'proposed',
585
- date: today,
586
- context: algo.context,
587
- decision: algo.decision,
588
- alternatives: algo.alternatives,
589
- consequences: algo.consequences,
590
- sparcSectionRefs: ['pseudocode.modules', 'architecture.services'],
591
- researchDossierItemRefs: findDossierRefs(dossier, algo.keywords),
592
- });
593
- }
594
- // ======================================================================
595
- // SPARC §A — Architecture: Per-service implementation decisions
596
- // Each service gets a SPECIFIC decision, not "build as modular service"
597
- // ======================================================================
598
- for (const svc of services.slice(0, 6)) {
599
- // Skip if the service name would duplicate the domain model or simulation ADR
600
- if (idx > 2 && /domain|model|simul|optim|engine/i.test(svc.name))
601
- continue;
602
- const depsText = svc.dependencies.length > 0
603
- ? svc.dependencies.join(', ')
604
- : '';
605
- const responsibility = svc.responsibility || svc.name;
606
- // Determine what KIND of service this is and generate a specific decision
607
- const isDataIngestion = /ingest|import|extract|collect|read|fetch|sync/i.test(responsibility);
608
- const isAnalysis = /analy|score|classif|evaluat|assess|rank|compar/i.test(responsibility);
609
- const isOrchestration = /orchestrat|workflow|pipeline|coordinat|schedul/i.test(responsibility);
610
- const isPresentation = /report|dashboard|visual|output|present|display|generat.*output/i.test(responsibility);
611
- const isGovernance = /approv|govern|audit|compliance|lineage|review/i.test(responsibility);
612
- let specificDecision;
613
- let specificTitle;
614
- let specificAlts;
615
- if (isGovernance) {
616
- specificTitle = `${svc.name}: Human-in-the-loop approval with immutable audit trail`;
617
- specificDecision = `Implement ${svc.name} as a state machine (draft → submitted → approved/rejected → executed). ` +
618
- `Every state transition is logged immutably with actor identity, timestamp, and decision rationale. ` +
619
- `Approval requires authenticated user with appropriate role — no self-approval, no programmatic bypass. ` +
620
- `${domain.erp ? 'Only APPROVED decisions can trigger ' + domain.erp + ' writeback.' : ''}`;
621
- specificAlts = [
622
- makeAlt('State machine with RBAC and immutable log', 'Meets audit and compliance requirements', false),
623
- makeAlt('Simple boolean approved flag', 'No state history, no role enforcement, no audit trail', true),
624
- makeAlt('Manual email-based approval', 'No programmatic enforcement, approval can be forged', true),
625
- ];
626
- }
627
- else if (isDataIngestion) {
628
- specificTitle = `${svc.name}: ${domain.erp ? domain.erp + ' data ingestion' : 'External data ingestion'} with validation`;
629
- specificDecision = `${svc.name} ingests data from ${depsText || (domain.erp ?? 'external sources')} through an anti-corruption layer. ` +
630
- `All incoming records are validated against domain schemas before entering the system. ` +
631
- `Invalid records are quarantined with error details, not silently dropped. ` +
632
- `Ingestion is idempotent — re-running the same import produces identical state.`;
633
- specificAlts = [
634
- makeAlt('ACL with schema validation and quarantine', 'Protects domain model from external data quality issues', false),
635
- makeAlt('Direct database import', 'Couples domain model to external schemas — any upstream change breaks the system', true),
636
- ];
637
- }
638
- else if (isAnalysis) {
639
- specificTitle = `${svc.name}: Domain-specific ${/score|rank/i.test(responsibility) ? 'scoring' : 'analysis'} with configurable weights`;
640
- specificDecision = `${svc.name} implements ${responsibility.slice(0, 100)}. ` +
641
- `Scoring weights and thresholds are externalized as configuration (not hardcoded constants) so domain experts can tune them without code changes. ` +
642
- `All analysis outputs include the weights used, enabling reproducibility and audit. ` +
643
- `${depsText ? 'Consumes data from: ' + depsText + '.' : ''}`;
644
- specificAlts = [
645
- makeAlt('Configurable analysis with weight transparency', 'Domain experts can tune; results are auditable', false),
646
- makeAlt('Hardcoded scoring rules', 'Faster initially but requires developer for every threshold change', true),
647
- ];
648
- }
649
- else if (isOrchestration) {
650
- specificTitle = `${svc.name}: Workflow orchestration with checkpoint and resume`;
651
- specificDecision = `${svc.name} coordinates: ${responsibility.slice(0, 120)}. ` +
652
- `Each workflow step is checkpointed so failures resume from last successful step, not from scratch. ` +
653
- `Workflow state is persisted (not in-memory) to survive process restarts. ` +
654
- `${depsText ? 'Orchestrates: ' + depsText + '.' : ''}`;
655
- specificAlts = [
656
- makeAlt('Persistent workflow with checkpoints', 'Resilient to failures, supports long-running processes', false),
657
- makeAlt('In-memory orchestration', 'Process restart loses all workflow state', true),
658
- ];
659
- }
660
- else if (isPresentation) {
661
- specificTitle = `${svc.name}: Decision-grade output generation with lineage`;
662
- specificDecision = `${svc.name} generates structured outputs for ${domain.stakeholders || 'leadership review'}. ` +
663
- `Every output traces back to the simulation/analysis that produced it (lineage hash). ` +
664
- `Outputs include: comparative metrics, tradeoff visualization data, confidence indicators, and assumption documentation. ` +
665
- `${domain.erp ? 'Optionally formats approved outputs for ' + domain.erp + ' writeback.' : ''}`;
666
- specificAlts = [
667
- makeAlt('Structured outputs with lineage tracing', 'Decision-makers can verify what analysis produced each recommendation', false),
668
- makeAlt('Free-form text reports', 'No traceability, no structured comparison', true),
669
- ];
670
- }
671
- else {
672
- // Generic but still domain-specific — use the actual responsibility text
673
- specificTitle = `${svc.name}: ${responsibility.slice(0, 70)}`;
674
- specificDecision = `Implement ${svc.name} to handle: ${responsibility}. ` +
675
- `${depsText ? 'Integrates with ' + depsText + ' via typed interfaces (not direct coupling). ' : ''}` +
676
- `Expose domain operations as a service API. All state mutations produce domain events for downstream consumers. ` +
677
- `${techStack.includes('Rust') && /comput|optim|graph|simul/i.test(responsibility) ? 'Implement compute-intensive logic in Rust.' : ''}`;
678
- specificAlts = [
679
- makeAlt(`Dedicated ${svc.name} service with domain API`, `Encapsulates ${domain.primary} business rules for ${svc.name}`, false),
680
- makeAlt('Inline in monolith', 'Couples ' + svc.name + ' logic to unrelated concerns', true),
681
- ];
682
- }
683
- adrs.push({
684
- id: `ADR-P2-${pad3(idx++)}`,
685
- title: specificTitle,
686
- status: 'proposed',
687
- date: today,
688
- context: `${domain.primary} requires: ${responsibility.slice(0, 200)}. ` +
689
- `${depsText ? 'Dependencies: ' + depsText + '. ' : ''}` +
690
- `This service is part of the ${domain.primary} solution.`,
691
- decision: specificDecision,
692
- alternatives: specificAlts,
693
- consequences: [
694
- makeCons(`${svc.name} business rules are testable in isolation`, 'positive'),
695
- makeCons(`Clear API contract for ${svc.name} enables parallel team development`, 'positive'),
696
- makeCons(`Requires explicit interface definition between ${svc.name} and its consumers`, 'negative'),
697
- ],
698
- sparcSectionRefs: [`architecture.services.${svc.name}`],
699
- researchDossierItemRefs: findDossierRefs(dossier, [svc.name, ...svc.dependencies]),
700
- });
701
- }
702
- // ======================================================================
703
- // SPARC §A — Architecture: ERP Integration ADR (if ERP detected)
704
- // ======================================================================
705
- if (domain.erp) {
706
- adrs.push({
707
- id: `ADR-P2-${pad3(idx++)}`,
708
- title: `${domain.erp} Integration: Anti-corruption layer with governance-gated writeback`,
709
- status: 'proposed',
710
- date: today,
711
- context: `${domain.primary} data originates in ${domain.erp}. The AI system reads operational data for analysis/simulation ` +
712
- `and optionally writes approved decisions back. Direct coupling to ${domain.erp} internals would make the domain model fragile.`,
713
- decision: `Build an anti-corruption layer (ACL) between ${domain.erp} and the ${domain.primary} domain. ` +
714
- `The ACL translates ${domain.erp} wire formats into domain types and vice versa. ` +
715
- `Read mode is the default; writeback is gated behind APPROVED governance status. ` +
716
- `Circuit breaker + sync queue handle ${domain.erp} outages. ` +
717
- `All ${domain.erp} operations carry idempotency keys to prevent duplicate writes on retry.`,
718
- alternatives: [
719
- makeAlt(`ACL with governance-gated writeback`, `Protects ${domain.erp} integrity; enables AI-assisted decisions without risk`, false),
720
- makeAlt(`Direct ${domain.erp} API calls from services`, `Tight coupling; ${domain.erp} schema changes break domain model`, true),
721
- makeAlt('Full data replication to local DB', `Stale data risk; doubles storage cost; compliance concerns with data residency`, true),
722
- ],
723
- consequences: [
724
- makeCons(`${domain.erp} schema changes don't cascade to domain services`, 'positive'),
725
- makeCons(`Writeback requires human approval — no accidental ${domain.erp} mutations`, 'positive'),
726
- makeCons(`ACL translation layer must track ${domain.erp} API version changes`, 'negative'),
727
- ],
728
- sparcSectionRefs: ['architecture.services'],
729
- researchDossierItemRefs: findDossierRefs(dossier, [domain.erp.toLowerCase(), 'erp', 'integration']),
730
- });
731
- }
732
- // ======================================================================
733
- // SPARC §A — Architecture: Data Persistence ADR (technology-specific)
734
- // ======================================================================
735
- const databases = techStack.filter(t => /postgres|mysql|bigquery|mongodb|redis|dynamodb|cloud sql/i.test(t));
736
- const cloudProvider = techStack.find(t => /google cloud|aws|azure|gcp/i.test(t));
737
- if (databases.length > 0 || cloudProvider) {
738
- adrs.push({
739
- id: `ADR-P2-${pad3(idx++)}`,
740
- title: `Data Persistence: ${[...databases, cloudProvider].filter(Boolean).join(' + ')} for ${domain.primary}`,
741
- status: 'proposed',
742
- date: today,
743
- context: `${domain.primary} data includes: domain entities, ${hasSimulation ? 'simulation results, ' : ''}audit trail, and operational records. ` +
744
- `User specified: ${techStack.join(', ')}. ` +
745
- `Audit data must be immutable. Simulation results must be reproducible.`,
746
- decision: databases.map(db => {
747
- if (/bigquery/i.test(db))
748
- return `**BigQuery** for analytical queries, historical trend analysis, and reporting aggregations.`;
749
- if (/postgres/i.test(db))
750
- return `**Cloud SQL (PostgreSQL)** for transactional domain data, audit logs, and governance state. Use row-level security for multi-tenant isolation.`;
751
- if (/redis/i.test(db))
752
- return `**Redis** for caching frequently-read reference data and session state.`;
753
- if (/mongodb/i.test(db))
754
- return `**MongoDB** for flexible document storage of simulation configurations and results.`;
755
- return `**${db}** for persistent domain data.`;
756
- }).join(' ') +
757
- (cloudProvider ? ` Deploy on ${cloudProvider} managed services to reduce operational burden.` : ''),
758
- alternatives: [
759
- makeAlt(`${databases.join(' + ')} on ${cloudProvider ?? 'managed cloud'}`, 'Matches specified tech stack; managed services reduce ops burden', false),
760
- makeAlt('Single-database architecture', 'Simpler but cannot optimize for both transactional and analytical workloads', true),
761
- makeAlt('In-memory only', 'Process restart destroys all data — compliance disqualifier', true),
762
- ],
763
- consequences: [
764
- makeCons('Polyglot persistence optimizes each data access pattern', 'positive'),
765
- makeCons(`${cloudProvider ?? 'Cloud'} managed services handle backups, scaling, and failover`, 'positive'),
766
- makeCons('Multiple data stores increase operational complexity and migration effort', 'negative'),
767
- ],
768
- sparcSectionRefs: ['architecture.services'],
769
- researchDossierItemRefs: findDossierRefs(dossier, ['data', 'database', 'storage', 'persistence', ...databases.map(d => d.toLowerCase())]),
770
- });
771
- }
772
- // ======================================================================
773
- // SPARC §R — Refinement: Security & Compliance ADR
774
- // ======================================================================
775
- const secText = itemsToText(securityItems);
776
- const hasCompliance = /audit|compliance|regulatory|governance|lineage|human.?in.?the.?loop|esg|esg/i.test(q);
777
- if (secText || hasCompliance) {
778
- const complianceTerms = (q.match(/audit(?:ability)?|compliance|regulatory|governance|ESG|lineage|human.?in.?the.?loop|GDPR|HIPAA|SOX/gi) ?? []);
779
- const uniqueTerms = [...new Set(complianceTerms.map(t => t.toLowerCase()))];
780
- adrs.push({
781
- id: `ADR-P2-${pad3(idx++)}`,
782
- title: `Security & Compliance: ${uniqueTerms.slice(0, 3).join(', ') || 'RBAC and audit trail'} for ${domain.primary}`,
783
- status: 'proposed',
784
- date: today,
785
- context: `${domain.primary} decisions affect ${domain.stakeholders || 'operational teams and leadership'}. ` +
786
- `${secText ? 'Security requirements: ' + secText + '. ' : ''}` +
787
- `${uniqueTerms.length > 0 ? 'Compliance domains: ' + uniqueTerms.join(', ') + '. ' : ''}` +
788
- `The system must maintain strict auditability: who decided what, when, and based on which analysis.`,
789
- decision: `Implement RBAC with domain-specific roles (not just admin/user). ` +
790
- `Define roles mapped to ${domain.primary} operations: who can run simulations, who can approve decisions, who can trigger ${domain.erp ?? 'ERP'} writeback. ` +
791
- `All state-changing operations produce immutable audit entries with actor identity, timestamp, and operation details. ` +
792
- `${hasSimulation ? 'Simulation results are write-once with content hash for tamper detection. ' : ''}` +
793
- `Human-in-the-loop is mandatory before any changes propagate to operational systems.`,
794
- alternatives: [
795
- makeAlt(`RBAC + immutable audit + ${hasSimulation ? 'simulation lineage' : 'operation lineage'}`, 'Meets regulatory requirements; provides full traceability', false),
796
- makeAlt('Simple API key authentication', 'No role differentiation; anyone with the key can approve decisions', true),
797
- makeAlt('Audit logging without RBAC', 'Records who did what but cannot prevent unauthorized actions', true),
798
- ],
799
- consequences: [
800
- makeCons(`Full decision traceability for ${uniqueTerms[0] ?? 'regulatory'} compliance`, 'positive'),
801
- makeCons('No automated execution without human approval', 'positive'),
802
- makeCons('Role definition and permission matrix must be maintained as organization evolves', 'negative'),
803
- makeCons('Immutable audit log storage grows continuously — requires archival strategy', 'negative'),
804
- ],
805
- sparcSectionRefs: ['refinement.securityConsiderations'],
806
- researchDossierItemRefs: findDossierRefs(dossier, ['security', 'auth', 'access', 'compliance', 'audit', 'governance']),
807
- });
808
- }
809
- // ======================================================================
810
- // SPARC §R — Refinement: Performance ADR (if Rust or compute-intensive)
811
- // ======================================================================
812
- const perfText = itemsToText(perfItems);
813
- if (perfText || techStack.includes('Rust')) {
814
- adrs.push({
815
- id: `ADR-P2-${pad3(idx++)}`,
816
- title: `Performance: ${techStack.includes('Rust') ? 'Rust compute engine + TypeScript orchestration' : 'Optimization strategy for ' + domain.primary}`,
817
- status: 'proposed',
818
- date: today,
819
- context: `${perfText ? 'Performance requirements: ' + perfText + '. ' : ''}` +
820
- `${techStack.includes('Rust') ? 'The user specified Rust for compute-intensive components and TypeScript for orchestration. ' : ''}` +
821
- `${hasSimulation ? 'Simulation/optimization workloads require bounded execution time for interactive use.' : ''}`,
822
- decision: `${techStack.includes('Rust') ? 'Split computation: Rust handles graph traversal, constraint solving, and numerical optimization. TypeScript handles API routing, workflow orchestration, and result formatting. Communication via typed JSON over stdin/stdout (subprocess) or FFI (napi-rs). ' : ''}` +
823
- `All compute operations have configurable timeouts. Results include execution time metadata. ` +
824
- `${hasSimulation ? 'Simulation parameter space is bounded (max iterations, max graph depth) to prevent runaway computation.' : ''}`,
825
- alternatives: [
826
- makeAlt(techStack.includes('Rust') ? 'Rust engine + TypeScript orchestration' : 'Targeted optimization of critical paths', 'Best performance where it matters; orchestration stays in TypeScript for productivity', false),
827
- makeAlt('Pure TypeScript', techStack.includes('Rust') ? 'Simpler build but 10-100x slower for graph/numeric workloads' : 'Simpler but may not meet latency targets', true),
828
- ],
829
- consequences: [
830
- makeCons(techStack.includes('Rust') ? 'Compute-intensive operations run at native speed' : 'Critical paths are optimized based on profiling data', 'positive'),
831
- makeCons(techStack.includes('Rust') ? 'Dual-language build pipeline increases CI/CD complexity' : 'Optimization effort must be validated with benchmarks', 'negative'),
832
- ],
833
- sparcSectionRefs: ['refinement.performanceTargets'],
834
- researchDossierItemRefs: findDossierRefs(dossier, ['performance', 'latency', 'throughput', 'rust', 'compute']),
835
- });
836
- }
837
- // ======================================================================
838
- // SPARC §R — Refinement: High-severity risk ADRs from dossier
839
- // ======================================================================
840
- const highRisks = dossier.items.filter(i => i.category === 'risk' && i.severity && /high|critical/i.test(i.severity));
841
- for (const risk of highRisks.slice(0, 2)) {
842
- adrs.push({
843
- id: `ADR-P2-${pad3(idx++)}`,
844
- title: `Risk Mitigation: ${risk.title.slice(0, 70)}`,
845
- status: 'proposed',
846
- date: today,
847
- context: risk.content,
848
- decision: `Mitigate "${risk.title}" with: (1) input validation at the service boundary where this risk originates, ` +
849
- `(2) monitoring and alerting for early detection of the failure mode, ` +
850
- `(3) graceful degradation path that preserves data integrity when the risk materializes. ` +
851
- `Document specific controls in the affected service's test suite.`,
852
- alternatives: [
853
- makeAlt('Active boundary controls + monitoring + degradation', 'Prevents impact; detects early; degrades gracefully', false),
854
- makeAlt('Accept risk and monitor', 'Lower effort but reactive — damage occurs before detection', true),
855
- ],
856
- consequences: [
857
- makeCons(`"${risk.title.slice(0, 40)}" mitigated with layered controls`, 'positive'),
858
- makeCons('Mitigation logic adds complexity to the affected service boundary', 'negative'),
859
- ],
860
- sparcSectionRefs: [],
861
- researchDossierItemRefs: [risk.id],
862
- });
863
- }
864
- // ======================================================================
865
- // SPARC §C — Completion: Deployment & Integration ADR
866
- // ======================================================================
867
- const deployTech = techStack.filter(t => /cloud run|cloud function|lambda|fargate|ecs|kubernetes|k8s|docker/i.test(t));
868
- if (deployTech.length > 0 || cloudProvider) {
869
- adrs.push({
870
- id: `ADR-P2-${pad3(idx++)}`,
871
- title: `Deployment: ${deployTech.join(' + ') || cloudProvider || 'Cloud'} with environment-based configuration`,
872
- status: 'proposed',
873
- date: today,
874
- context: `${domain.primary} services deploy to ${cloudProvider ?? 'cloud infrastructure'}. ` +
875
- `${deployTech.length > 0 ? 'Specified: ' + deployTech.join(', ') + '. ' : ''}` +
876
- `Services must be stateless (state in databases), independently deployable, and environment-configurable.`,
877
- decision: `Deploy each service as a stateless container on ${deployTech[0] || cloudProvider || 'managed cloud compute'}. ` +
878
- `All configuration via environment variables (database URLs, API keys, feature flags) — zero hardcoded secrets. ` +
879
- `Health checks on every service endpoint. Structured JSON logging to cloud logging service. ` +
880
- `${integrationPlan ? 'Integration plan: ' + integrationPlan.slice(0, 150) + '.' : ''}`,
881
- alternatives: [
882
- makeAlt(`${deployTech[0] || 'Serverless'} with env-based config`, 'Matches specified infrastructure; scales automatically', false),
883
- makeAlt('VM-based deployment', 'More control but higher operational overhead', true),
884
- ],
885
- consequences: [
886
- makeCons('Zero-downtime deployments with rolling updates', 'positive'),
887
- makeCons('Environment parity (dev/staging/prod) via configuration only', 'positive'),
888
- makeCons('Cold start latency for serverless (mitigated with min instances)', 'negative'),
889
- ],
890
- sparcSectionRefs: ['completion.integrationPlan'],
891
- researchDossierItemRefs: findDossierRefs(dossier, ['deploy', 'cloud', 'infrastructure', 'container']),
892
- });
893
- }
894
- // ======================================================================
895
- // ADR-PIPELINE-028: Guaranteed Minimum ADR Set
896
- // Every pipeline run MUST produce ≥8 ADRs regardless of SPARC richness.
897
- // These fire only when the conditional branches above didn't cover them.
898
- // ======================================================================
899
- function hasADRCovering(...keywords) {
900
- return adrs.some(a => {
901
- const text = `${a.title} ${a.decision}`.toLowerCase();
902
- return keywords.some(kw => text.includes(kw));
903
- });
904
- }
905
- // 1. Recommendation-only pattern (human-in-the-loop)
906
- if (!hasADRCovering('human-in-the-loop', 'recommendation-only', 'no auto-execution', 'approval')) {
907
- adrs.push({
908
- id: `ADR-P2-${pad3(idx++)}`,
909
- title: `${domain.primary}: Recommendation-Only Pattern — No Automated Execution`,
910
- status: 'proposed',
911
- date: today,
912
- context: `The ${domain.primary} system analyzes data and generates recommendations. ` +
913
- `Users explicitly stated the system should not automatically enforce decisions. ` +
914
- `All actions that affect operational systems require human review and approval.`,
915
- decision: `The system operates in recommendation-only mode. AI analysis produces ranked options with tradeoff data. ` +
916
- `No recommendation is executed automatically — every action requires explicit human approval via the governance workflow. ` +
917
- `${domain.erp ? `${domain.erp} writeback is blocked until a decision reaches APPROVED status.` : 'External system writes are blocked until approval.'}`,
918
- alternatives: [
919
- makeAlt('Recommendation-only with human approval gate', 'Preserves decision authority; meets governance requirements', false),
920
- makeAlt('Auto-execution with override option', 'Faster but removes human judgment from consequential decisions', true),
921
- makeAlt('Advisory-only with no action pathway', 'Safe but provides no mechanism to act on insights', true),
922
- ],
923
- consequences: [
924
- makeCons('Decision authority stays with domain experts, not the AI system', 'positive'),
925
- makeCons('Full audit trail of who approved what and why', 'positive'),
926
- makeCons('Slower time-to-action compared to automated execution', 'negative'),
927
- ],
928
- sparcSectionRefs: [],
929
- researchDossierItemRefs: findDossierRefs(dossier, ['approval', 'human', 'recommend', 'decision']),
930
- });
931
- }
932
- // 2. ERP integration strategy
933
- if (!hasADRCovering('erp', 'integration', 'anti-corruption', 'writeback') && domain.erp) {
934
- adrs.push({
935
- id: `ADR-P2-${pad3(idx++)}`,
936
- title: `${domain.erp} Integration: API-Based Read with Governance-Gated Writeback`,
937
- status: 'proposed',
938
- date: today,
939
- context: `${domain.primary} data resides in ${domain.erp}. The system must read operational data for analysis ` +
940
- `and optionally write approved decisions back. Direct coupling to ${domain.erp} internals would create fragility.`,
941
- decision: `Integrate via ${domain.erp} standard APIs with an anti-corruption layer. ` +
942
- `Read operations use standard API endpoints with pagination and rate limit handling. ` +
943
- `Write operations are gated behind APPROVED governance status — no unapproved writes. ` +
944
- `All API calls include idempotency keys to prevent duplicate operations on retry.`,
945
- alternatives: [
946
- makeAlt(`${domain.erp} API with ACL`, 'Decouples domain from ERP schema changes', false),
947
- makeAlt('Direct database access', 'Bypasses API rate limits but couples to internal schema', true),
948
- makeAlt('File-based batch export/import', 'Simple but no real-time capability and error-prone', true),
949
- ],
950
- consequences: [
951
- makeCons(`${domain.erp} schema changes don't cascade to domain logic`, 'positive'),
952
- makeCons('Writeback requires explicit approval — prevents accidental data mutation', 'positive'),
953
- makeCons('API rate limits may constrain data refresh frequency', 'negative'),
954
- ],
955
- sparcSectionRefs: [],
956
- researchDossierItemRefs: findDossierRefs(dossier, ['erp', 'integration', domain.erp?.toLowerCase() ?? '']),
957
- });
958
- }
959
- // 3. Data model and domain entity design
960
- if (!hasADRCovering('domain model', 'bounded context', 'entity graph', 'data model')) {
961
- adrs.push({
962
- id: `ADR-P2-${pad3(idx++)}`,
963
- title: `${domain.primary}: Domain Model with Typed Entities and Validation`,
964
- status: 'proposed',
965
- date: today,
966
- context: `The ${domain.primary} system must model domain entities with strict type safety ` +
967
- `and validation at system boundaries. Data flows from ${domain.erp ?? 'external sources'} through analysis to decisions.`,
968
- decision: `Define all domain entities as TypeScript interfaces with discriminated union types for status fields. ` +
969
- `Validate all external data at ingestion boundaries using runtime schema validation. ` +
970
- `Domain entities are immutable value objects — state changes produce new instances with audit trail.`,
971
- alternatives: [
972
- makeAlt('Typed domain model with boundary validation', 'Prevents invalid state; enables exhaustive pattern matching', false),
973
- makeAlt('Loose JSON objects', 'Flexible but runtime errors from missing/wrong fields', true),
974
- ],
975
- consequences: [
976
- makeCons('Type errors caught at compile time, not runtime', 'positive'),
977
- makeCons('Requires upfront type definition effort', 'negative'),
978
- ],
979
- sparcSectionRefs: ['specification.requirements'],
980
- researchDossierItemRefs: findDossierRefs(dossier, ['data', 'model', 'entity', 'type']),
981
- });
982
- }
983
- // 4. Scoring/ranking algorithm
984
- if (!hasADRCovering('scoring', 'ranking', 'algorithm', 'weight')) {
985
- adrs.push({
986
- id: `ADR-P2-${pad3(idx++)}`,
987
- title: `${domain.primary}: Multi-Factor Scoring with Configurable Weights`,
988
- status: 'proposed',
989
- date: today,
990
- context: `The system must prioritize and rank ${domain.primary.toLowerCase()} items by multiple criteria. ` +
991
- `Different stakeholders may weight criteria differently (cost vs. sustainability vs. operational impact).`,
992
- decision: `Implement a multi-factor scoring engine with externalized, configurable weights. ` +
993
- `Every score includes the weight vector used, enabling reproducibility and audit. ` +
994
- `Default weights are calibrated from domain benchmarks but can be overridden per analysis run.`,
995
- alternatives: [
996
- makeAlt('Multi-factor scoring with configurable weights', 'Transparent, auditable, domain-expert tunable', false),
997
- makeAlt('Single-metric ranking', 'Simpler but ignores tradeoff complexity', true),
998
- makeAlt('ML-based ranking', 'Potentially more accurate but requires training data and is less explainable', true),
999
- ],
1000
- consequences: [
1001
- makeCons('Domain experts can tune priorities without code changes', 'positive'),
1002
- makeCons('Score decomposition enables "why this ranking?" explanations', 'positive'),
1003
- makeCons('Weight selection requires domain expertise and periodic review', 'negative'),
1004
- ],
1005
- sparcSectionRefs: ['pseudocode.modules'],
1006
- researchDossierItemRefs: findDossierRefs(dossier, ['score', 'rank', 'priority', 'weight', 'criteria']),
1007
- });
1008
- }
1009
- // 5. Audit trail and decision traceability
1010
- if (!hasADRCovering('audit trail', 'traceability', 'immutable', 'audit log')) {
1011
- adrs.push({
1012
- id: `ADR-P2-${pad3(idx++)}`,
1013
- title: `${domain.primary}: Immutable Audit Trail with Decision Lineage`,
1014
- status: 'proposed',
1015
- date: today,
1016
- context: `Decisions in ${domain.primary.toLowerCase()} affect ${domain.stakeholders || 'operational teams, compliance, and leadership'}. ` +
1017
- `Regulatory and governance requirements demand a clear record of how insights are generated and decisions are made.`,
1018
- decision: `Implement append-only audit trail. Every analysis run, recommendation, approval, and ${domain.erp ?? 'system'} action ` +
1019
- `is logged with: timestamp, actor identity, action type, input parameters, and outcome. ` +
1020
- `Audit entries are immutable — corrections create new entries referencing the original. ` +
1021
- `Decision lineage traces from ${domain.erp ?? 'data source'} → analysis → recommendation → approval → action.`,
1022
- alternatives: [
1023
- makeAlt('Immutable append-only audit log', 'Complete traceability; meets compliance requirements', false),
1024
- makeAlt('Mutable log table', 'Allows corrections but breaks audit integrity', true),
1025
- makeAlt('No structured audit', 'Relies on application logs which are not queryable or tamper-evident', true),
1026
- ],
1027
- consequences: [
1028
- makeCons('Any decision can be traced back to the analysis that produced it', 'positive'),
1029
- makeCons('Compliance reviewers can verify the decision chain end-to-end', 'positive'),
1030
- makeCons('Audit log storage grows continuously — requires archival strategy', 'negative'),
1031
- ],
1032
- sparcSectionRefs: [],
1033
- researchDossierItemRefs: findDossierRefs(dossier, ['audit', 'compliance', 'governance', 'trace']),
1034
- });
1035
- }
1036
- // 6. Monolithic prototype vs. microservice architecture
1037
- if (!hasADRCovering('monolith', 'prototype', 'microservice')) {
1038
- adrs.push({
1039
- id: `ADR-P2-${pad3(idx++)}`,
1040
- title: `${domain.primary}: Monolithic Library for Prototype, Decomposable for Production`,
1041
- status: 'proposed',
1042
- date: today,
1043
- context: `The SPARC architecture specifies multiple services. For the prototype evaluation period, ` +
1044
- `a monolithic library with clean module boundaries is more appropriate than premature microservice decomposition.`,
1045
- decision: `Build as a single TypeScript library with clean module boundaries (analysis/, decisions/, erp/, data/). ` +
1046
- `Each module has a barrel export and communicates via typed interfaces, not shared state. ` +
1047
- `This structure enables decomposition into independent services when the pilot validates the approach.`,
1048
- alternatives: [
1049
- makeAlt('Monolithic library with module boundaries', 'Fast to build, test, and demo; decomposable later', false),
1050
- makeAlt('Microservices from day one', 'Premature complexity for a prototype; slows iteration', true),
1051
- makeAlt('Single-file script', 'Fast but untestable, unmaintainable, and impossible to decompose', true),
1052
- ],
1053
- consequences: [
1054
- makeCons('Prototype can be built and demonstrated quickly', 'positive'),
1055
- makeCons('Module boundaries enforce separation of concerns without deployment overhead', 'positive'),
1056
- makeCons('Must resist the temptation to create cross-module shortcuts that prevent later decomposition', 'negative'),
1057
- ],
1058
- sparcSectionRefs: ['architecture.services'],
1059
- researchDossierItemRefs: [],
1060
- });
1061
- }
1062
- // 7. Human-in-the-loop approval workflow
1063
- if (!hasADRCovering('approval workflow', 'approval chain', 'governance workflow', 'state machine')) {
1064
- adrs.push({
1065
- id: `ADR-P2-${pad3(idx++)}`,
1066
- title: `${domain.primary}: Approval Workflow with Role-Based Gates`,
1067
- status: 'proposed',
1068
- date: today,
1069
- context: `${domain.primary} recommendations must be reviewed and approved before affecting operational systems. ` +
1070
- `The approval process must support multiple approvers and record the rationale for each decision.`,
1071
- decision: `Implement approval as a state machine: draft → pending_review → approved/rejected. ` +
1072
- `Each transition requires an authenticated actor with appropriate role. ` +
1073
- `Approval records include: approver identity, timestamp, rationale text, and the specific recommendation version approved. ` +
1074
- `Only APPROVED items can trigger downstream actions (${domain.erp ?? 'ERP'} writes, policy changes).`,
1075
- alternatives: [
1076
- makeAlt('State machine with role-based gates', 'Enforces governance; provides audit trail for every transition', false),
1077
- makeAlt('Email-based approval', 'No programmatic enforcement; approvals can be lost or forged', true),
1078
- makeAlt('Auto-approve after timeout', 'Violates human-in-the-loop requirement', true),
1079
- ],
1080
- consequences: [
1081
- makeCons('Every action has a traceable approval chain', 'positive'),
1082
- makeCons('Role-based gates prevent unauthorized approvals', 'positive'),
1083
- makeCons('Approval process adds latency between recommendation and action', 'negative'),
1084
- ],
1085
- sparcSectionRefs: [],
1086
- researchDossierItemRefs: findDossierRefs(dossier, ['approval', 'governance', 'workflow', 'review']),
1087
- });
1088
- }
1089
- // 8. Synthetic data model for prototype validation
1090
- if (!hasADRCovering('synthetic data', 'seed data', 'prototype data', 'test data')) {
1091
- adrs.push({
1092
- id: `ADR-P2-${pad3(idx++)}`,
1093
- title: `${domain.primary}: Synthetic Seed Data for Prototype Demonstration`,
1094
- status: 'proposed',
1095
- date: today,
1096
- context: `The prototype must demonstrate the full analysis pipeline without requiring access to production ${domain.erp ?? 'ERP'} data. ` +
1097
- `Seed data must be realistic enough to produce meaningful analysis results and demonstrate edge cases.`,
1098
- decision: `Generate synthetic seed data that models realistic ${domain.primary.toLowerCase()} scenarios: ` +
1099
- `multiple regions, varying data quality, both well-performing and problematic entities. ` +
1100
- `Include edge cases that exercise all analysis branches (high/medium/low scores, different categories). ` +
1101
- `Seed data is clearly labeled as synthetic and isolated from production data paths.`,
1102
- alternatives: [
1103
- makeAlt('Synthetic seed data with realistic distributions', 'Available immediately; exercises all code paths; no data privacy risk', false),
1104
- makeAlt('Anonymized production data', 'More realistic but requires data pipeline, privacy review, and access approval', true),
1105
- makeAlt('No seed data (production-only)', 'Blocks prototype demonstration until production access is granted', true),
1106
- ],
1107
- consequences: [
1108
- makeCons('Prototype can be demonstrated on day 1 without production data access', 'positive'),
1109
- makeCons('Edge cases can be deliberately modeled to stress-test analysis logic', 'positive'),
1110
- makeCons('Synthetic data may not capture all production data quality issues', 'negative'),
1111
- ],
1112
- sparcSectionRefs: [],
1113
- researchDossierItemRefs: findDossierRefs(dossier, ['data', 'prototype', 'seed', 'synthetic', 'test']),
1114
- });
1115
- }
1116
- process.stderr.write(`[adr-generator] buildADRsFromSPARC produced ${adrs.length} ADRs (minimum 8 guaranteed by ADR-PIPELINE-028)\n`);
1117
- return adrs;
1118
- }
1119
- function extractDomainContext(query) {
1120
- // Extract primary domain — look for noun phrases that describe the system's purpose
1121
- let primary = 'System';
1122
- const domainPatterns = [
1123
- /(?:global|enterprise)\s+([\w\s-]{5,60}?)(?:\s+(?:strategy|program|platform|system|initiative))/i,
1124
- /(?:help\s+(?:model|optimize|manage|simulate|build))\s+(.{10,60}?)(?:\.|,|$)/i,
1125
- /(?:challenge.*?is|goal.*?is)\s+(.{10,60}?)(?:\.|,|$)/i,
1126
- /\b((?:supplier|waste|emission|carbon|inventory|financial|clinical|manufacturing|procurement|supply chain)\s+[\w\s-]{3,40}?(?:optimization|management|simulation|tracking|analysis|modeling|reduction|transition))/i,
1127
- ];
1128
- for (const pat of domainPatterns) {
1129
- const m = query.match(pat);
1130
- if (m) {
1131
- primary = (m[1] ?? m[0]).trim().replace(/^(a|an|the)\s+/i, '');
1132
- primary = primary.charAt(0).toUpperCase() + primary.slice(1);
1133
- if (primary.length > 60)
1134
- primary = primary.slice(0, 57) + '...';
1135
- break;
1136
- }
1137
- }
1138
- if (primary === 'System') {
1139
- const fallback = query.match(/\b((?:global|enterprise|manufacturing|waste|carbon|emission|clinical|financial|supply|supplier|inventory)\s+\w+(?:\s+\w+){0,2})/i);
1140
- if (fallback?.[1])
1141
- primary = fallback[1].charAt(0).toUpperCase() + fallback[1].slice(1);
1142
- }
1143
- // ERP detection
1144
- let erp = null;
1145
- const erpPatterns = [
1146
- [/\bworkday\b/i, 'Workday'],
1147
- [/\boracle\s+fusion\s+cloud\b/i, 'Oracle Fusion Cloud'],
1148
- [/\boracle\s+(?:erp|cloud)\b/i, 'Oracle ERP Cloud'],
1149
- [/\bsap\s+s\/4\s*hana\b/i, 'SAP S/4HANA'],
1150
- [/\bsap\b/i, 'SAP'],
1151
- [/\bdynamics\s*365\b/i, 'Dynamics 365'],
1152
- [/\bnetsuite\b/i, 'NetSuite'],
1153
- [/\byardi\s+voyager\b/i, 'Yardi Voyager'],
1154
- [/\byardi\b/i, 'Yardi Voyager'],
1155
- ];
1156
- // ADR-PIPELINE-025: String-matching extraction for any system name
1157
- // NOTE: The original code used require() which crashes in ESM context.
1158
- // Replaced with inline string matching to avoid the module loading issue entirely.
1159
- if (!erp) {
1160
- const systemTriggers = [
1161
- 'managed through ', 'managed by ', 'managed via ', 'managed with ',
1162
- 'managed using ', 'managed in ', 'inside ',
1163
- ];
1164
- const ql = query.toLowerCase();
1165
- for (const trigger of systemTriggers) {
1166
- const idx = ql.indexOf(trigger);
1167
- if (idx === -1)
1168
- continue;
1169
- const after = query.slice(idx + trigger.length).trim();
1170
- // Extract 1-3 words as the system name
1171
- const nameMatch = after.match(/^([A-Z][\w]*(?:\s+[A-Z][\w]*){0,2})/);
1172
- if (nameMatch?.[1] && nameMatch[1].length > 2) {
1173
- erp = nameMatch[1].replace(/\s+ERP$/i, '').trim();
1174
- break;
1175
- }
1176
- }
1177
- }
1178
- for (const [pat, name] of erpPatterns) {
1179
- if (pat.test(query)) {
1180
- erp = name;
1181
- break;
1182
- }
1183
- }
1184
- // Stakeholders
1185
- const stakeholderMatch = query.match(/(?:affect|impact|involve)\s+(.{10,120}?)(?:\.|$)/i);
1186
- const matchedStakeholders = (query.match(/(?:procurement|sustainability|operations|executive|leadership|compliance|regulatory)\s+(?:team|leadership|management)/gi) ?? []).join(', ');
1187
- const stakeholders = stakeholderMatch?.[1]?.trim()
1188
- ?? (matchedStakeholders || 'operational teams and leadership');
1189
- return { primary, erp, stakeholders };
1190
- }
1191
- function extractTechFromQuery(query) {
1192
- const techs = [];
1193
- const patterns = [
1194
- [/\btypescript\b/i, 'TypeScript'], [/\brust\b/i, 'Rust'], [/\bpython\b/i, 'Python'],
1195
- [/\bgoogle cloud\b/i, 'Google Cloud'], [/\bcloud run\b/i, 'Cloud Run'], [/\bcloud functions?\b/i, 'Cloud Functions'],
1196
- [/\baws\b/i, 'AWS'], [/\bazure\b/i, 'Azure'],
1197
- [/\bpostgres(?:ql)?\b/i, 'PostgreSQL'], [/\bcloud sql\b/i, 'Cloud SQL (PostgreSQL)'],
1198
- [/\bbigquery\b/i, 'BigQuery'], [/\bmongodb\b/i, 'MongoDB'], [/\bredis\b/i, 'Redis'],
1199
- [/\bkafka\b/i, 'Kafka'], [/\brabbitmq\b/i, 'RabbitMQ'],
1200
- [/\bkubernetes\b|\bk8s\b/i, 'Kubernetes'], [/\bdocker\b/i, 'Docker'],
1201
- ];
1202
- for (const [pat, name] of patterns) {
1203
- if (pat.test(query) && !techs.includes(name))
1204
- techs.push(name);
1205
- }
1206
- return techs;
1207
- }
1208
- function itemsToText(items) {
1209
- return items.slice(0, 3).map(s => {
1210
- if (typeof s === 'string')
1211
- return s;
1212
- if (s && typeof s === 'object') {
1213
- const obj = s;
1214
- return String(obj['text'] ?? obj['description'] ?? JSON.stringify(s));
1215
- }
1216
- return String(s);
1217
- }).join('; ');
1218
- }
1219
- export function buildPhase2ADRs(sparc, dossier, query, skipLLM = false) {
143
+ export async function buildPhase2ADRs(sparc, dossier, query) {
1220
144
  if (!query) {
1221
- process.stderr.write('[adr-generator] No query provided — using template ADRs\n');
1222
- return buildADRsFromSPARC(sparc, dossier, query);
1223
- }
1224
- if (!skipLLM) {
1225
- // 1. Try Claude Code runner (works when NOT in MCP mode)
1226
- const runnerResult = tryGenerateLLMADRs(sparc, dossier, query);
1227
- if (runnerResult)
1228
- return runnerResult;
1229
- // 2. Try direct Anthropic API (works in MCP mode, needs API key)
1230
- const apiResult = tryGenerateADRsViaAPI(sparc, dossier, query);
1231
- if (apiResult)
1232
- return apiResult;
1233
- process.stderr.write('[adr-generator] LLM paths unavailable. Using SPARC-derived ADRs.\n');
1234
- }
1235
- else {
1236
- process.stderr.write('[adr-generator] LLM skipped (fallback path). Using SPARC-derived ADRs.\n');
1237
- }
1238
- // 3. SPARC-derived ADRs (template, always available, no blocking calls)
1239
- process.stderr.write('[adr-generator] Set ANTHROPIC_API_KEY to enable LLM-generated ADRs.\n');
1240
- return buildADRsFromSPARC(sparc, dossier, query);
145
+ process.stderr.write('[adr-generator] No query provided — emitting loud-failure ADR (ADR-PIPELINE-100 §D5)\n');
146
+ return [buildLoudFailureADR({
147
+ reason: 'No query string available — Phase 1 manifest/scenario did not surface the user prompt.',
148
+ phase1Status: 'missing-query',
149
+ dossierItemCount: dossier.items.length,
150
+ })];
151
+ }
152
+ const runnerResult = await tryGenerateLLMADRs(sparc, dossier, query);
153
+ if (runnerResult && runnerResult.length > 0)
154
+ return runnerResult;
155
+ const claudeFailureReason = 'tryGenerateLLMADRs returned no parseable markdown ADRs (timeout, claude binary missing, or empty output)';
156
+ const apiResult = await tryGenerateADRsViaAPI(sparc, dossier, query);
157
+ if (apiResult && apiResult.length > 0)
158
+ return apiResult;
159
+ const apiFailureReason = 'tryGenerateADRsViaAPI returned no parseable markdown ADRs (no ANTHROPIC_API_KEY, network, timeout, or rate-limit)';
160
+ process.stderr.write('[adr-generator] All LLM transports failed emitting loud-failure ADR (ADR-PIPELINE-100 §D5)\n');
161
+ return [buildLoudFailureADR({
162
+ reason: 'All LLM transports failed to return parseable markdown ADRs.',
163
+ claudeFailureReason,
164
+ apiFailureReason,
165
+ dossierItemCount: dossier.items.length,
166
+ sparcStatus: (sparc.architecture?.services?.length ?? 0) > 0 ? 'present' : 'missing-services',
167
+ query,
168
+ })];
1241
169
  }
1242
170
  /**
1243
171
  * Render a list of ADR records as a single markdown document.
@@ -1269,66 +197,42 @@ export function renderADRsAsDocument(adrs, query) {
1269
197
  * one-shot subprocess that does NOT use MCP — safe to call from any context.
1270
198
  * Uses the user's Claude Max subscription (no separate API key needed).
1271
199
  */
1272
- function tryGenerateLLMADRs(sparc, dossier, query, phase2Dir = '') {
200
+ async function tryGenerateLLMADRs(sparc, dossier, query, phase2Dir = '') {
1273
201
  try {
1274
- // Resolve claude binary directly — do NOT use isClaudeCodeRunnerAvailable()
1275
- // which blocks in MCP mode. `claude --print` is safe from any context.
1276
- // execFileSync imported at top of file
1277
- let claudeBin = null;
1278
- // Check env override first
1279
- const envBin = process.env['AGENTICS_CLAUDE_BIN'];
1280
- if (envBin) {
1281
- claudeBin = envBin;
1282
- }
1283
- else {
1284
- // Find claude on PATH
1285
- const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
1286
- for (const candidate of ['claude', 'claude-code']) {
1287
- try {
1288
- const found = execFileSync(lookupCmd, [candidate], {
1289
- stdio: ['pipe', 'pipe', 'pipe'],
1290
- timeout: 5_000,
1291
- encoding: 'utf-8',
1292
- }).trim().split(/\r?\n/)[0]?.trim();
1293
- if (found) {
1294
- claudeBin = found;
1295
- break;
1296
- }
1297
- }
1298
- catch { /* not found, try next */ }
1299
- }
1300
- }
202
+ const claudeBin = resolveClaudeBin();
1301
203
  if (!claudeBin) {
1302
204
  process.stderr.write('[adr-generator] claude binary not found — cannot generate LLM ADRs\n');
1303
205
  return null;
1304
206
  }
1305
- const prompt = buildADRPrompt(sparc, dossier, query, phase2Dir);
1306
- const startMs = Date.now();
1307
- // Use --print for one-shot generation (no MCP, no interactive session)
207
+ const delimiter = newDocBreakDelimiter();
208
+ const prompt = buildADRPrompt(sparc, dossier, query, delimiter, phase2Dir);
1308
209
  const model = process.env['AGENTICS_CLAUDE_MODEL'] || 'claude-sonnet-4-20250514';
1309
- process.stderr.write(`[adr-generator] Invoking ${claudeBin} (${model}) for rich ADR generation — up to 6 minutes\n`);
1310
- const rawOutput = execFileSync(claudeBin, [
1311
- '--print',
1312
- '--output-format', 'text',
1313
- '--model', model,
1314
- prompt,
1315
- ], {
1316
- encoding: 'utf-8',
1317
- // ADR-PIPELINE-101 rich ADRs (architecture diagrams + per-component
1318
- // code examples + appendices) need substantially more output than the
1319
- // pre-101 thin format. Bump to 6 minutes.
1320
- timeout: 360_000,
1321
- maxBuffer: 32 * 1024 * 1024,
1322
- stdio: ['pipe', 'pipe', 'pipe'],
1323
- env: { ...process.env, MCP_SERVER_MODE: undefined }, // Clear MCP flag for subprocess
1324
- });
1325
- const adrs = parseRawADRs(rawOutput, dossier);
210
+ const timeoutMs = parseAdrTimeoutMs();
211
+ const startMs = Date.now();
212
+ const result = await runStreamedClaudePrint(claudeBin, prompt, model, timeoutMs, phase2Dir);
213
+ if (!result.ok) {
214
+ process.stderr.write(`[adr-generator] Claude --print stream failed: ${result.reason} ` +
215
+ `(received ${result.stdoutBytes} bytes over ${result.elapsedMs}ms)\n`);
216
+ // ADR-PIPELINE-100 §D3: streamed output is preserved on timeout/kill, so
217
+ // we still try to parse what came back — if the LLM finished the first
218
+ // few ADRs before the cap, those are usable.
219
+ if (result.stdout && result.stdoutBytes > 1024) {
220
+ const partial = parseMarkdownADRs(result.stdout, delimiter, dossier);
221
+ if (partial && partial.length > 0) {
222
+ process.stderr.write(`[adr-generator] Salvaged ${partial.length} ADR(s) from partial stream output\n`);
223
+ return partial;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ const adrs = parseMarkdownADRs(result.stdout, delimiter, dossier);
1326
229
  if (!adrs || adrs.length === 0) {
1327
- process.stderr.write('[adr-generator] Claude --print returned no valid ADR JSON\n');
230
+ process.stderr.write('[adr-generator] Claude --print returned no parseable markdown ADRs\n');
1328
231
  return null;
1329
232
  }
1330
233
  const durationMs = Date.now() - startMs;
1331
- process.stderr.write(`[adr-generator] LLM-generated ${adrs.length} domain-specific ADRs via claude --print in ${durationMs}ms\n`);
234
+ process.stderr.write(`[adr-generator] LLM-generated ${adrs.length} domain-specific ADRs via streamed claude --print ` +
235
+ `in ${durationMs}ms (${result.stdoutBytes} bytes, markdown-direct, ADR-PIPELINE-100)\n`);
1332
236
  return adrs;
1333
237
  }
1334
238
  catch (err) {
@@ -1336,20 +240,232 @@ function tryGenerateLLMADRs(sparc, dossier, query, phase2Dir = '') {
1336
240
  return null;
1337
241
  }
1338
242
  }
243
+ /**
244
+ * Locate the `claude` CLI binary for `--print` invocation.
245
+ * Honors `AGENTICS_CLAUDE_BIN` env override first, then probes PATH for
246
+ * `claude` and `claude-code`. Returns null when no binary is available.
247
+ */
248
+ function resolveClaudeBin() {
249
+ const envBin = process.env['AGENTICS_CLAUDE_BIN'];
250
+ if (envBin)
251
+ return envBin;
252
+ const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
253
+ for (const candidate of ['claude', 'claude-code']) {
254
+ try {
255
+ const found = execFileSync(lookupCmd, [candidate], {
256
+ stdio: ['pipe', 'pipe', 'pipe'],
257
+ timeout: 5_000,
258
+ encoding: 'utf-8',
259
+ }).trim().split(/\r?\n/)[0]?.trim();
260
+ if (found)
261
+ return found;
262
+ }
263
+ catch { /* not found, try next */ }
264
+ }
265
+ return null;
266
+ }
267
+ /**
268
+ * ADR-PIPELINE-100 §D3: streamed `claude --print` subprocess.
269
+ *
270
+ * Replaces buffered execFileSync with `child_process.spawn` so:
271
+ * - stdout is collected incrementally (partial output preserved on kill)
272
+ * - stderr is forwarded live to the user (visible Claude Code progress)
273
+ * - a 10-second heartbeat reports elapsed time and bytes-received so the
274
+ * terminal isn't silent during long generations
275
+ * - a 60-second idle bound kills the process if no output has been received
276
+ * in a minute (catches hung subprocesses without waiting for the hard cap)
277
+ * - a hard timeout of `timeoutMs` (default 30 minutes) is the absolute upper
278
+ * bound; runaway generations are killed and reported as failures
279
+ *
280
+ * Returns `{ ok: false }` with the partial `stdout` populated when the
281
+ * subprocess is killed or exits non-zero. Caller may attempt to salvage
282
+ * partial output rather than discarding the whole run.
283
+ */
284
+ async function runStreamedClaudePrint(claudeBin, prompt, model, timeoutMs, phase2Dir) {
285
+ const startMs = Date.now();
286
+ const idleBoundMs = parseInt(process.env['AGENTICS_ADR_IDLE_MS'] || '', 10) || 60_000;
287
+ const heartbeatMs = parseInt(process.env['AGENTICS_ADR_HEARTBEAT_MS'] || '', 10) || 10_000;
288
+ return new Promise((resolve) => {
289
+ let stdout = '';
290
+ let stderr = '';
291
+ let lastDataMs = Date.now();
292
+ let killed = false;
293
+ let killReason;
294
+ const child = spawn(claudeBin, [
295
+ '--print',
296
+ '--output-format', 'text',
297
+ '--model', model,
298
+ prompt,
299
+ ], {
300
+ stdio: ['pipe', 'pipe', 'pipe'],
301
+ env: { ...process.env, MCP_SERVER_MODE: undefined },
302
+ });
303
+ process.stderr.write(`[adr-generator] Streaming claude --print (model=${model}, timeout=${Math.floor(timeoutMs / 1000)}s, idle=${Math.floor(idleBoundMs / 1000)}s)\n`);
304
+ const debugLogPath = phase2Dir ? path.join(phase2Dir, 'claude-stderr.log') : null;
305
+ let debugStream = null;
306
+ if (debugLogPath) {
307
+ try {
308
+ fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
309
+ debugStream = fs.createWriteStream(debugLogPath, { flags: 'a' });
310
+ }
311
+ catch { /* best-effort */ }
312
+ }
313
+ child.stdout?.on('data', (chunk) => {
314
+ const text = chunk.toString('utf-8');
315
+ stdout += text;
316
+ lastDataMs = Date.now();
317
+ });
318
+ child.stderr?.on('data', (chunk) => {
319
+ const text = chunk.toString('utf-8');
320
+ stderr += text;
321
+ // Forward Claude Code's stderr live so the user sees progress / warnings.
322
+ // ADR-PIPELINE-099 heartbeat compatibility: prefix with [claude] so the
323
+ // terminal scroller can identify the source.
324
+ process.stderr.write(`[claude] ${text}`);
325
+ debugStream?.write(text);
326
+ });
327
+ const heartbeat = setInterval(() => {
328
+ const elapsedSec = Math.floor((Date.now() - startMs) / 1000);
329
+ const bytes = stdout.length;
330
+ const idleSec = Math.floor((Date.now() - lastDataMs) / 1000);
331
+ process.stderr.write(`[agentics] ⏳ ADR generation streaming — ${elapsedSec}s elapsed, ${bytes} bytes received, ${idleSec}s since last byte\n`);
332
+ }, heartbeatMs);
333
+ const idleCheck = setInterval(() => {
334
+ if (Date.now() - lastDataMs > idleBoundMs && !killed) {
335
+ killed = true;
336
+ killReason = `idle bound exceeded (no output for ${Math.floor((Date.now() - lastDataMs) / 1000)}s)`;
337
+ process.stderr.write(`[adr-generator] Killing claude --print: ${killReason}\n`);
338
+ try {
339
+ child.kill('SIGTERM');
340
+ }
341
+ catch { /* already dead */ }
342
+ }
343
+ }, Math.min(heartbeatMs, 5000));
344
+ const hardTimeout = setTimeout(() => {
345
+ if (!killed) {
346
+ killed = true;
347
+ killReason = `hard timeout exceeded (${Math.floor(timeoutMs / 1000)}s)`;
348
+ process.stderr.write(`[adr-generator] Killing claude --print: ${killReason}\n`);
349
+ try {
350
+ child.kill('SIGTERM');
351
+ }
352
+ catch { /* already dead */ }
353
+ // SIGKILL backstop in case SIGTERM is ignored.
354
+ setTimeout(() => { try {
355
+ child.kill('SIGKILL');
356
+ }
357
+ catch { /* already dead */ } }, 5000);
358
+ }
359
+ }, timeoutMs);
360
+ child.on('error', (err) => {
361
+ clearInterval(heartbeat);
362
+ clearInterval(idleCheck);
363
+ clearTimeout(hardTimeout);
364
+ debugStream?.end();
365
+ resolve({
366
+ ok: false,
367
+ reason: `spawn error: ${err.message}`,
368
+ stdout,
369
+ stderr,
370
+ stdoutBytes: stdout.length,
371
+ elapsedMs: Date.now() - startMs,
372
+ exitCode: null,
373
+ });
374
+ });
375
+ child.on('close', (code) => {
376
+ clearInterval(heartbeat);
377
+ clearInterval(idleCheck);
378
+ clearTimeout(hardTimeout);
379
+ debugStream?.end();
380
+ const elapsedMs = Date.now() - startMs;
381
+ const ok = !killed && code === 0;
382
+ resolve({
383
+ ok,
384
+ reason: ok ? undefined : (killReason ?? `claude --print exited with code ${code}`),
385
+ stdout,
386
+ stderr,
387
+ stdoutBytes: stdout.length,
388
+ elapsedMs,
389
+ exitCode: code,
390
+ });
391
+ });
392
+ });
393
+ }
394
+ /**
395
+ * Read `AGENTICS_ADR_TIMEOUT_MS` from env (default 30 minutes per
396
+ * ADR-PIPELINE-100 §D3). Clamps to [60s, 60min] to prevent both runaway runs
397
+ * and accidental zero/negative values.
398
+ */
399
+ function parseAdrTimeoutMs() {
400
+ const raw = process.env['AGENTICS_ADR_TIMEOUT_MS'];
401
+ const parsed = raw ? Number(raw) : NaN;
402
+ const def = 30 * 60 * 1000; // 30 min
403
+ if (!Number.isFinite(parsed) || parsed <= 0)
404
+ return def;
405
+ const min = 60 * 1000; // 1 min lower bound
406
+ const max = 60 * 60 * 1000; // 60 min upper bound
407
+ return Math.max(min, Math.min(max, parsed));
408
+ }
1339
409
  // ============================================================================
1340
- // ADR Generation via Direct Anthropic API (for MCP mode where runner is blocked)
410
+ // ADR-PIPELINE-100: Markdown-direct ADR generation
1341
411
  // ============================================================================
412
+ //
413
+ // The LLM emits a sequence of full markdown ADR documents separated by a
414
+ // per-run UUID delimiter. We split on the delimiter, write each chunk as its
415
+ // own .md file, and best-effort extract a small set of structured fields
416
+ // (title, status, date, phaseTags) for the index. The full markdown is the
417
+ // canonical artifact — we never re-serialise it from the structured fields.
418
+ const DOC_BREAK_PREFIX = '---ADR-DOC-BREAK-';
419
+ const DOC_BREAK_SUFFIX = '---';
420
+ /** Generate a per-run document delimiter for the LLM prompt. */
421
+ function newDocBreakDelimiter() {
422
+ return `${DOC_BREAK_PREFIX}${randomUUID()}${DOC_BREAK_SUFFIX}`;
423
+ }
424
+ /**
425
+ * Locate and load the canonical good-ADR exemplar shipped with the repo.
426
+ * Tries CWD first, then walks up from this module's location to find the
427
+ * `docs/templates/ADR-Good-Example.md` file (handles src/ vs dist/ layouts).
428
+ * Returns the empty string when not found — the prompt still works without
429
+ * an exemplar but the output will be lower quality.
430
+ */
431
+ function loadGoodAdrExemplar() {
432
+ const candidates = [];
433
+ candidates.push(path.resolve(process.cwd(), 'docs/templates/ADR-Good-Example.md'));
434
+ try {
435
+ const here = fileURLToPath(import.meta.url);
436
+ let dir = path.dirname(here);
437
+ for (let i = 0; i < 8 && dir !== path.dirname(dir); i++) {
438
+ candidates.push(path.join(dir, 'docs', 'templates', 'ADR-Good-Example.md'));
439
+ dir = path.dirname(dir);
440
+ }
441
+ }
442
+ catch { /* import.meta unavailable in some test contexts */ }
443
+ for (const p of candidates) {
444
+ try {
445
+ if (fs.existsSync(p))
446
+ return fs.readFileSync(p, 'utf-8');
447
+ }
448
+ catch { /* ignore unreadable candidates */ }
449
+ }
450
+ return '';
451
+ }
1342
452
  /**
1343
453
  * Build the ADR generation prompt from SPARC + dossier + query.
1344
454
  * Shared between tryGenerateLLMADRs (Claude Code runner) and tryGenerateADRsViaAPI (direct API).
455
+ *
456
+ * ADR-PIPELINE-100 §D1: prompt instructs the LLM to emit a sequence of full
457
+ * markdown ADR documents separated by `delimiter`. The good-ADR exemplar is
458
+ * embedded verbatim as a STRUCTURAL reference, with explicit instruction that
459
+ * the input domain comes from the project requirements section, not the
460
+ * exemplar. This keeps the generator domain-agnostic.
1345
461
  */
1346
- function buildADRPrompt(sparc, dossier, query, phase2Dir = '') {
462
+ function buildADRPrompt(sparc, dossier, query, delimiter, phase2Dir = '') {
1347
463
  const services = sparc.architecture?.services ?? [];
1348
- const serviceList = services.map(s => `- ${s.name}: ${s.responsibility.slice(0, 100)} (deps: ${s.dependencies.join(', ') || 'none'})`).join('\n');
464
+ const serviceList = services.map(s => `- ${s.name}: ${s.responsibility.slice(0, 200)} (deps: ${s.dependencies.join(', ') || 'none'})`).join('\n');
1349
465
  const dossierContext = dossier.items
1350
466
  .filter(i => ['requirement', 'constraint', 'risk', 'decision-rationale'].includes(i.category))
1351
- .slice(0, 20)
1352
- .map(i => `- [${i.category}] ${i.title}: ${i.content.slice(0, 120)}`)
467
+ .slice(0, 30)
468
+ .map(i => `- [${i.category}] ${i.title}: ${i.content.slice(0, 240)}`)
1353
469
  .join('\n');
1354
470
  const agentContext = phase2Dir ? loadAgenticsFleetContextForADR(phase2Dir) : '';
1355
471
  const dossierText = dossier.items.map(i => i.content).join(' ');
@@ -1368,258 +484,419 @@ function buildADRPrompt(sparc, dossier, query, phase2Dir = '') {
1368
484
  if (techStack.cacheLayer)
1369
485
  techMentions.push(techStack.cacheLayer);
1370
486
  const erpPromptContext = techStack.erp
1371
- ? `\nERP API DETAILS (use these EXACT paths):\n${formatERPContextForPrompt(techStack.erp)}\n`
487
+ ? `\n## ERP integration surface (use these EXACT paths)\n${formatERPContextForPrompt(techStack.erp)}\n`
488
+ : '';
489
+ const exemplar = loadGoodAdrExemplar();
490
+ const exemplarBlock = exemplar
491
+ ? `\n## Structural exemplar (the bar to match)\n\n` +
492
+ `The following is a canonical "good" ADR from a different domain (vector database). ` +
493
+ `**Match its structure, depth, and rigour for the input domain below.** ` +
494
+ `Do NOT reuse its subject matter — your input is the project requirements section, ` +
495
+ `not the exemplar. Match these structural elements:\n\n` +
496
+ `- Header block: Status / Date / Authors / Deciders / Builds-on / **Implementation Phase**\n` +
497
+ `- Context with sub-sections and at least one comparison table\n` +
498
+ `- Decision section with an architecture diagram (ASCII art or mermaid)\n` +
499
+ `- Per-component subsections with code samples (typed signatures, struct/class definitions)\n` +
500
+ `- Real source-file paths consistent with the project's tech stack\n` +
501
+ `- Configuration parameters table (param / default / description)\n` +
502
+ `- Per-platform or per-mode performance characteristics where relevant\n` +
503
+ `- Numbered Alternatives Considered with multi-line "Rejected because:" rationales\n` +
504
+ `- Consequences split into Benefits, Risks (table with Probability/Impact/Mitigation), Performance Targets\n` +
505
+ `- Implementation Status table\n` +
506
+ `- Related Decisions cross-links\n` +
507
+ `- Revision History table\n\n` +
508
+ `<EXEMPLAR>\n${exemplar}\n</EXEMPLAR>\n`
1372
509
  : '';
1373
- return (`You are a senior staff architect writing detailed Architecture Decision Records (ADRs) for a real engineering team.\n\n` +
1374
- `Each ADR you produce MUST be a long-form, implementation-grade document that includes ASCII architecture diagrams, named components with code examples, configuration tables, performance targets, security guarantees, references, and appendices — NOT a 5-bullet skeleton. A developer must be able to take ONE of your ADRs and build the slice it describes without asking follow-up questions. Reference shape: 400-800 lines of markdown per ADR when rendered.\n\n` +
510
+ return (`You are a senior solution architect writing Architecture Decision Records.\n\n` +
511
+ `Read the FULL project requirements, research findings, and detected technologies below, then write 8 to 14 ` +
512
+ `ADRs as full markdown documents that guide a developer on HOW to build the specific system the user described.\n\n` +
1375
513
  `## Project Requirements\n\n${query}\n\n` +
1376
- `## Services Identified\n${serviceList}\n` +
1377
- `\n## Research Findings\n${dossierContext}\n` +
1378
- (agentContext ? '\n' + agentContext + '\n' : '') +
1379
- `\n## Detected Technologies\n${techMentions.join(', ') || 'not specified'}\n` +
514
+ `## Services Identified\n${serviceList || '(none — derive from requirements)'}\n` +
515
+ `\n## Research Findings\n${dossierContext || '(no dossier items available)'}\n` +
516
+ (agentContext ? agentContext + '\n' : '') +
517
+ `\n## Detected Technologies\n${techMentions.join(', ') || '(none specified — pick reasonable defaults and justify them)'}\n` +
1380
518
  erpPromptContext +
1381
- `\n## Output Contract\n\n` +
1382
- `Return ONLY a JSON array of 8-12 ADRs (no markdown fences, no preamble). Each ADR object MUST have this shape:\n` +
1383
- '```json\n' +
1384
- `[\n` +
1385
- ` {\n` +
1386
- ` "title": "Specific decision title what the developer is building (e.g. 'Model water usage as facility-level 15-min time series with cooling-zone partitioning and weather correlation')",\n` +
1387
- ` "authors": ["Solution Architecture", "Sustainability Operations"],\n` +
1388
- ` "deciders": ["Architecture Review Board"],\n` +
1389
- ` "context": "3-6 paragraphs. Sub-section the problem space (the challenge, current state, target state). Cite specific requirements from the brief. NEVER one paragraph.",\n` +
1390
- ` "decision": "3-6 paragraphs. State the chosen approach concretely with named modules, schemas, thresholds. Cite ERP API paths if applicable. NEVER one sentence.",\n` +
1391
- ` "architectureDiagram": "ASCII box diagram showing how data/control flows through this slice. 8-15 lines. Use +-+, |, ->, etc.",\n` +
1392
- ` "components": [\n` +
1393
- ` {\n` +
1394
- ` "name": "Concrete module/class name (e.g. 'WaterUsageIngestor', 'CoolingScenarioEvaluator')",\n` +
1395
- ` "description": "1-2 paragraphs explaining what this component does and why it exists.",\n` +
1396
- ` "codeExample": { "language": "typescript", "code": "// 5-25 lines of REAL working code interface, function signature, schema, query, etc. Not pseudo-code." },\n` +
1397
- ` "configTable": { "headers": ["Parameter", "Default", "Description"], "rows": [["window_size_min", "15", "Aggregation window for time-series rollup"], ["..."], ["..."]] },\n` +
1398
- ` "performanceCharacteristics": ["Bullet list of measurable perf claims (e.g. 'p99 ingest latency < 200ms at 50K events/sec')"],\n` +
1399
- ` "securityNotes": ["Bullet list of threat-model notes (e.g. 'Approval signatures use Ed25519, stored in HSM-backed key vault')"]\n` +
1400
- ` }\n` +
1401
- ` ],\n` +
1402
- ` "performanceTargets": { "headers": ["Metric", "Target", "Measurement"], "rows": [["Ingestion throughput", ">= 50K events/sec", "Per region, sustained 1h"], ["Query p95", "< 500ms", "Facility-level rollup, last 7 days"]] },\n` +
1403
- ` "alternatives": [\n` +
1404
- ` { "option": "Concrete alternative name (not 'Approach A')", "rationale": "Why it was considered and why rejected/selected. 1-3 sentences.", "rejected": true }\n` +
1405
- ` ],\n` +
1406
- ` "consequences": [\n` +
1407
- ` { "description": "Concrete consequence what becomes easier/harder/possible/impossible. Reference specific downstream impact.", "type": "positive" }\n` +
1408
- ` ],\n` +
1409
- ` "implementationStatus": { "headers": ["Component", "Status", "Notes"], "rows": [["WaterUsageIngestor", "Planned", "Spec complete, awaiting Phase 5"], ["CoolingScenarioEvaluator", "Planned", "Depends on factor library"]] },\n` +
1410
- ` "references": [\n` +
1411
- ` "ASHRAE TC 9.9 Thermal Guidelines for Data Processing Environments (2021)",\n` +
1412
- ` "ServiceNow Sustainability Management Water Use API: https://developer.servicenow.com/...",\n` +
1413
- ` "Belady, C. Carbon-Aware Computing in Hyperscale Data Centers (2022)"\n` +
1414
- ` ],\n` +
1415
- ` "appendices": [\n` +
1416
- ` { "title": "ServiceNow Field Mapping", "body": "Markdown table or prose mapping ADR concepts to ServiceNow CMDB tables (cmdb_ci_facility, sn_sustain_*) with example record shapes." },\n` +
1417
- ` { "title": "Sample Query", "body": "A realistic query/snippet showing how this ADR's slice is exercised end-to-end. Fenced code block." }\n` +
1418
- ` ]\n` +
1419
- ` }\n` +
1420
- `]\n` +
1421
- '```\n' +
1422
- `\n## Coverage Requirements\n\n` +
1423
- `A) DOMAIN-SPECIFIC DECISIONS (at least 5):\n` +
1424
- ` - How to model the core domain (data shape, partitioning, time semantics)\n` +
1425
- ` - How to implement key algorithms (scoring, anomaly detection, scenario comparison, recommendation ranking)\n` +
1426
- ` - How decision/simulation/optimization logic is structured\n` +
1427
- ` - How domain entities relate and what business rules constrain them\n` +
1428
- ` - How specific user-described workflows are wired end-to-end\n` +
1429
- ` Each non-obvious algorithmic choice gets its OWN ADR: scoring/normalization, threshold models, factor sourcing, estimation fallbacks, immutability/audit patterns.\n\n` +
1430
- `B) INFRASTRUCTURE DECISIONS (3-5):\n` +
1431
- ` - Data persistence (which database, why, schema sketch)\n` +
1432
- ` - ERP integration for ${techMentions[0] || 'the named ERP'} (concrete API paths, idempotency, retry, rate limits)\n` +
1433
- ` - Deployment + cloud platform specifics\n` +
1434
- ` - Security, compliance, audit trail (RBAC, separation of duties, hash chain)\n\n` +
1435
- `## Critical Rules\n\n` +
1436
- `- NO generic titles like "Security Architecture", "Performance Architecture", "Service Boundary".\n` +
1437
- `- Every ADR's "decision" field is 3-6 paragraphs, NEVER one sentence.\n` +
1438
- `- "context" is 3-6 paragraphs explaining the problem space + current state + target state.\n` +
1439
- `- "components" has 2-5 entries per ADR, each with a real code example.\n` +
1440
- `- "appendices" has at least 1 entry (e.g. ERP field mapping, sample queries, threshold tables).\n` +
1441
- `- Reference actual domain concepts from the brief — never generic "entities" or "services".\n` +
1442
- `- If the user named technologies (${techMentions.join(', ')}), every relevant ADR cites them BY NAME with concrete API paths.\n` +
1443
- `- ASCII diagrams MUST show real component names from this project, not abstract boxes labelled "Service A".\n` +
1444
- `- Code examples MUST compile (or be syntactically valid pseudo-code with real types).\n` +
1445
- `- References MUST include at least one industry standard / RFC / vendor doc URL where applicable.\n\n` +
1446
- `Return ONLY the JSON array. No prose before or after. No markdown fences around the array.`);
519
+ exemplarBlock +
520
+ `\n## Output format (CRITICAL read carefully)\n\n` +
521
+ `Emit ${techMentions.length > 0 ? '10 to 14' : '8 to 12'} ADRs as a sequence of full markdown documents.\n` +
522
+ `Separate consecutive ADR documents with **exactly** this delimiter on its own line:\n\n` +
523
+ `${delimiter}\n\n` +
524
+ `Do not use that string anywhere else in your output. Do not wrap the response in code fences. ` +
525
+ `Do not include any preamble, postscript, or explanation outside the ADR documents.\n\n` +
526
+ `Each ADR document MUST start with:\n\n` +
527
+ `\`\`\`\n` +
528
+ `# ADR-P2-NNN: <specific decision title>\n\n` +
529
+ `**Status:** Proposed\n` +
530
+ `**Date:** ${new Date().toISOString().slice(0, 10)}\n` +
531
+ `**Implementation Phase:** <foundation|domain|integration|api|deployment|operations|cross-cutting>\n` +
532
+ `\`\`\`\n\n` +
533
+ `where NNN is a zero-padded sequential number starting at 001. The Implementation Phase tag drives ` +
534
+ `phase-aware ADR injection in the implementation prompts (ADR-PIPELINE-100 §D4): use \`foundation\` ` +
535
+ `for repo scaffolding and core types, \`domain\` for domain models and business logic, \`integration\` ` +
536
+ `for adapters/connectors/anti-corruption layers, \`api\` for HTTP/RPC surfaces, \`deployment\` for ` +
537
+ `infrastructure/CI/Docker, \`operations\` for observability/logging/metrics, \`cross-cutting\` for ` +
538
+ `decisions that apply across all phases (security, governance, audit).\n\n` +
539
+ `## Coverage requirements\n\n` +
540
+ `The complete set of ADRs must cover BOTH categories:\n\n` +
541
+ `**A) Domain-specific decisions (at least 5)** — implementation of the actual business logic from the ` +
542
+ `requirements. Each non-obvious algorithmic choice gets its own ADR: scoring/ranking, routing/filtering, ` +
543
+ `factor sourcing, threshold models, estimation fallbacks, immutability/audit patterns, etc.\n\n` +
544
+ `**B) Infrastructure decisions (3-5)** — supporting technology: data persistence, ` +
545
+ `${techMentions[0] ? techMentions[0] + ' integration approach' : 'integration approach'}, deployment, security/compliance.\n\n` +
546
+ `## Title rules\n\n` +
547
+ `- GOOD: "Use Monte Carlo simulation for carbon price forecasting with configurable volatility"\n` +
548
+ `- BAD: "Performance Architecture"\n` +
549
+ `- GOOD: "Model emissions baseline as facility-level time series with business unit rollup"\n` +
550
+ `- BAD: "Data persistence strategy"\n\n` +
551
+ `Each title describes a specific decision the developer must build, not a category.\n\n` +
552
+ `## Quality bar\n\n` +
553
+ `- Reference actual domain concepts from the requirements (not generic "entities" or "services")\n` +
554
+ `- If the user specified technologies (${techMentions.join(', ') || 'none specified'}), reference them in relevant ADRs by name\n` +
555
+ `- Decision sections must be deep enough that a developer can build from them multiple paragraphs, code samples where appropriate, file paths, named types\n` +
556
+ `- Alternatives Considered must include 2-4 options with substantive rejection rationales (not strawmen)\n` +
557
+ `- Consequences must include both benefits and risks; risks should be in a table with probability/impact/mitigation columns\n` +
558
+ `- Match the structural depth of the exemplar above for the user's domain\n\n` +
559
+ `Begin output now. First ADR's \`# ADR-P2-001:\` heading should be the very first line. ` +
560
+ `No preamble.`);
1447
561
  }
1448
562
  /**
1449
- * Parse raw LLM JSON response into Phase2ADRRecord[].
563
+ * Parse a raw LLM markdown response into Phase2ADRRecord[].
564
+ * ADR-PIPELINE-100 §D1: split on the per-run delimiter, then per-chunk extract
565
+ * a flat metadata head (id/title/status/date/phaseTags) and store the full
566
+ * chunk as `rawMarkdown`. Tolerant to minor heading variation.
1450
567
  */
1451
- function parseRawADRs(rawText, dossier) {
1452
- // Strip markdown code fences if present
568
+ function parseMarkdownADRs(rawText, delimiter, dossier) {
569
+ if (!rawText || !delimiter)
570
+ return null;
571
+ // Strip surrounding code fences if the LLM wrapped its output despite
572
+ // instruction not to.
1453
573
  let cleaned = rawText.trim();
1454
574
  if (cleaned.startsWith('```')) {
1455
- cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
1456
- }
1457
- let rawAdrs;
1458
- try {
1459
- const parsed = JSON.parse(cleaned);
1460
- if (!Array.isArray(parsed) || parsed.length === 0)
1461
- return null;
1462
- rawAdrs = parsed;
1463
- }
1464
- catch {
1465
- // Try to extract JSON array from the response
1466
- const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
1467
- if (!arrayMatch)
1468
- return null;
1469
- try {
1470
- const parsed = JSON.parse(arrayMatch[0]);
1471
- if (!Array.isArray(parsed) || parsed.length === 0)
1472
- return null;
1473
- rawAdrs = parsed;
1474
- }
1475
- catch {
1476
- return null;
575
+ cleaned = cleaned.replace(/^```[a-z]*\s*\n?/i, '').replace(/\n?```\s*$/, '');
576
+ }
577
+ // Primary split: per-run UUID delimiter.
578
+ let chunks = cleaned
579
+ .split(delimiter)
580
+ .map(c => c.trim())
581
+ .filter(c => c.length > 0);
582
+ // Secondary split: if the model ignored the delimiter and emitted multiple
583
+ // ADRs separated by `# ADR-...` headers, recover by splitting on those.
584
+ if (chunks.length <= 1) {
585
+ const headerSplits = cleaned.split(/(?=^#\s+ADR[-\s])/m).map(c => c.trim()).filter(c => c.length > 0);
586
+ if (headerSplits.length > 1) {
587
+ chunks = headerSplits;
1477
588
  }
1478
589
  }
590
+ if (chunks.length === 0)
591
+ return null;
1479
592
  const today = new Date().toISOString().slice(0, 10);
1480
- return rawAdrs.map((raw, idx) => {
1481
- const alts = Array.isArray(raw.alternatives)
1482
- ? raw.alternatives.map(a => makeAlt(String(a.option || ''), String(a.rationale || ''), !!a.rejected))
1483
- : [makeAlt('Selected approach', String(raw.decision || ''), false)];
1484
- const cons = Array.isArray(raw.consequences)
1485
- ? raw.consequences.map(c => makeCons(String(c.description || ''), (c.type || 'neutral')))
1486
- : [makeCons('Decision applied', 'neutral')];
1487
- // ADR-PIPELINE-101: thread the rich-content fields if the LLM produced them.
1488
- return {
1489
- id: `ADR-P2-${pad3(idx + 1)}`,
1490
- title: String(raw.title || `Decision ${idx + 1}`),
1491
- status: 'proposed',
1492
- date: today,
1493
- context: String(raw.context || ''),
1494
- decision: String(raw.decision || ''),
1495
- alternatives: alts,
1496
- consequences: cons,
593
+ const records = [];
594
+ for (let i = 0; i < chunks.length; i++) {
595
+ const md = chunks[i];
596
+ const meta = extractAdrMetaFromMarkdown(md, i + 1);
597
+ // Best-effort structured-field extraction for backward-compat consumers.
598
+ const context = extractMdSection(md, /^##\s+context\b/im) || '';
599
+ const decision = extractMdSection(md, /^##\s+decision\b/im) || '';
600
+ const alternatives = extractAlternativesFromMd(md, decision);
601
+ const consequences = extractConsequencesFromMd(md);
602
+ records.push({
603
+ id: meta.id,
604
+ title: meta.title,
605
+ status: meta.status,
606
+ date: meta.date || today,
607
+ context,
608
+ decision,
609
+ alternatives,
610
+ consequences,
1497
611
  sparcSectionRefs: [],
1498
- researchDossierItemRefs: findDossierRefs(dossier, [
1499
- String(raw.title || '').toLowerCase(),
1500
- ]),
1501
- ...parseRichADRFields(raw),
1502
- };
1503
- });
612
+ researchDossierItemRefs: findDossierRefs(dossier, [meta.title.toLowerCase()]),
613
+ rawMarkdown: md.endsWith('\n') ? md : md + '\n',
614
+ phaseTags: meta.phaseTags,
615
+ });
616
+ }
617
+ return records.length > 0 ? records : null;
1504
618
  }
1505
619
  /**
1506
- * Extract the optional ADR-PIPELINE-101 rich-content fields (architectureDiagram,
1507
- * components, performanceTargets, references, appendices, implementationStatus,
1508
- * authors, deciders) from an LLM JSON object. Returns a partial object — caller
1509
- * spreads it onto the base record so missing fields stay undefined.
620
+ * Extract a flat metadata head (id, title, status, date, phaseTags) from a
621
+ * single ADR markdown chunk. Tolerant of minor variation; falls back to a
622
+ * generated ID if the H1 is missing or malformed.
1510
623
  */
1511
- function parseRichADRFields(raw) {
1512
- const out = {};
1513
- if (Array.isArray(raw.authors)) {
1514
- out.authors = raw.authors.map(String).filter(Boolean);
1515
- }
1516
- if (Array.isArray(raw.deciders)) {
1517
- out.deciders = raw.deciders.map(String).filter(Boolean);
624
+ function extractAdrMetaFromMarkdown(md, fallbackIndex) {
625
+ // H1 like `# ADR-P2-001: Some Title` or `# ADR-PIPELINE-100: Some Title`
626
+ const h1Re = /^#\s+(ADR[-\w]*\d+)[:\s]+(.+?)\s*$/m;
627
+ const h1 = md.match(h1Re);
628
+ const id = h1?.[1]?.trim() || `ADR-P2-${pad3(fallbackIndex)}`;
629
+ let title = (h1?.[2] || '').trim();
630
+ if (!title) {
631
+ // Fallback: first H1 line, anything after the colon
632
+ const anyH1 = md.match(/^#\s+(.+?)\s*$/m);
633
+ title = anyH1?.[1]?.trim() || `Decision ${fallbackIndex}`;
634
+ title = title.replace(/^ADR[-\w]*\d+\s*[:\s]+/i, '').trim() || `Decision ${fallbackIndex}`;
635
+ }
636
+ const statusLine = md.match(/^\*\*Status:\*\*\s*(.+?)\s*$/im);
637
+ const statusText = (statusLine?.[1] || '').toLowerCase();
638
+ const status = statusText.includes('failed') ? 'failed'
639
+ : statusText.includes('deprecated') ? 'deprecated'
640
+ : statusText.includes('accepted') ? 'accepted'
641
+ : 'proposed';
642
+ const dateLine = md.match(/^\*\*Date:\*\*\s*(.+?)\s*$/im);
643
+ const date = (dateLine?.[1] || '').trim();
644
+ const phaseLine = md.match(/^\*\*Implementation Phase:\*\*\s*(.+?)\s*$/im);
645
+ const phaseTags = phaseLine?.[1]
646
+ ? phaseLine[1].split(/[,/|]+/).map(s => s.trim().toLowerCase()).filter(Boolean)
647
+ : [];
648
+ return { id, title, status, date, phaseTags };
649
+ }
650
+ /**
651
+ * Extract the body text under a heading regex until the next heading at the
652
+ * same or higher level (or end of document). Returns trimmed body or empty
653
+ * string if the heading is absent.
654
+ */
655
+ function extractMdSection(md, headingRe) {
656
+ const match = headingRe.exec(md);
657
+ if (!match)
658
+ return '';
659
+ const headingEnd = match.index + match[0].length;
660
+ const after = md.slice(headingEnd);
661
+ // Stop at the next H1 or H2 (don't stop at H3+ which are subsections of this
662
+ // section).
663
+ const nextHeadingMatch = /^(?:#{1,2})\s+/m.exec(after);
664
+ const sectionBody = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
665
+ return sectionBody.trim();
666
+ }
667
+ /**
668
+ * Best-effort alternatives extraction from a markdown ADR. Looks under
669
+ * `## Alternatives Considered` for either bullet items or `### N. <option>`
670
+ * subsections, classifying each as rejected/selected by keyword search.
671
+ */
672
+ function extractAlternativesFromMd(md, decisionText) {
673
+ const section = extractMdSection(md, /^##\s+alternatives?(?:\s+considered)?\b/im);
674
+ if (!section) {
675
+ // Synthesise a single "selected approach" alternative from the decision
676
+ // text so backward-compat consumers see something non-empty.
677
+ return decisionText
678
+ ? [makeAlt('Selected approach', decisionText.split(/\n\s*\n/)[0]?.slice(0, 400) ?? '', false)]
679
+ : [];
680
+ }
681
+ const alts = [];
682
+ // Pattern A: `### N. Title` subsections
683
+ const subRe = /^###\s+(?:\d+[.)]\s*)?(.+?)\s*$/gm;
684
+ let m;
685
+ const positions = [];
686
+ while ((m = subRe.exec(section)) !== null) {
687
+ positions.push({
688
+ start: m.index,
689
+ headerEnd: m.index + m[0].length,
690
+ option: m[1].trim(),
691
+ });
1518
692
  }
1519
- if (typeof raw.architectureDiagram === 'string' && raw.architectureDiagram.trim()) {
1520
- out.architectureDiagram = raw.architectureDiagram;
693
+ if (positions.length > 0) {
694
+ for (let i = 0; i < positions.length; i++) {
695
+ const cur = positions[i];
696
+ const nextStart = i + 1 < positions.length ? positions[i + 1].start : section.length;
697
+ const body = section.slice(cur.headerEnd, nextStart).trim();
698
+ const rejected = /\brejected\b|\bnot\s+(?:selected|chosen)\b/i.test(body);
699
+ alts.push(makeAlt(cur.option, body.slice(0, 600), rejected));
700
+ }
701
+ return alts;
702
+ }
703
+ // Pattern B: bullet list items
704
+ const bulletRe = /^\s*[-*]\s+\*\*(.+?)\*\*\s*(?:\(([^)]*)\))?:?\s*(.*)$/gm;
705
+ while ((m = bulletRe.exec(section)) !== null) {
706
+ const option = m[1].trim();
707
+ const annotation = (m[2] || '').toLowerCase();
708
+ const rationale = (m[3] || '').trim();
709
+ const rejected = /reject|not.*select/i.test(annotation) || /reject/i.test(rationale);
710
+ alts.push(makeAlt(option, rationale.slice(0, 600), rejected));
711
+ }
712
+ if (alts.length > 0)
713
+ return alts;
714
+ // Fallback: each non-empty line is an alternative
715
+ const lines = section.split(/\n+/).map(l => l.trim()).filter(l => l.length > 0).slice(0, 6);
716
+ for (const line of lines) {
717
+ const rejected = /reject|not.*select/i.test(line);
718
+ alts.push(makeAlt(line.slice(0, 200), line.slice(0, 600), rejected));
719
+ }
720
+ return alts;
721
+ }
722
+ /**
723
+ * Best-effort consequences extraction from a markdown ADR. Looks under
724
+ * `## Consequences` for bullet items or sub-sections labelled
725
+ * Benefits/Positive vs Risks/Negative.
726
+ */
727
+ function extractConsequencesFromMd(md) {
728
+ const section = extractMdSection(md, /^##\s+consequences\b/im);
729
+ if (!section)
730
+ return [];
731
+ const cons = [];
732
+ // Pattern: bullet items with optional [+]/[-]/[~] markers
733
+ const bulletRe = /^\s*[-*]\s+(?:\[\s*([+\-~])\s*\]\s*)?(.+?)\s*$/gm;
734
+ let m;
735
+ while ((m = bulletRe.exec(section)) !== null) {
736
+ const marker = m[1];
737
+ const text = m[2].trim();
738
+ if (text.length < 8)
739
+ continue; // skip tiny noise lines
740
+ const type = marker === '+' ? 'positive'
741
+ : marker === '-' ? 'negative'
742
+ : marker === '~' ? 'neutral'
743
+ : /\b(benefit|advantage|enabl|reduce|improv|gain)/i.test(text) ? 'positive'
744
+ : /\b(risk|cost|drawback|disadvantage|limit|degrad|require.*invest)/i.test(text) ? 'negative'
745
+ : 'neutral';
746
+ cons.push(makeCons(text.slice(0, 400), type));
747
+ }
748
+ // If no bullets parsed, synthesise from sub-section headers
749
+ if (cons.length === 0) {
750
+ const benefits = extractMdSection(section, /^###\s+benefits?\b/im);
751
+ const risks = extractMdSection(section, /^###\s+risks?\b/im);
752
+ if (benefits)
753
+ cons.push(makeCons(benefits.split(/\n\s*\n/)[0].slice(0, 400), 'positive'));
754
+ if (risks)
755
+ cons.push(makeCons(risks.split(/\n\s*\n/)[0].slice(0, 400), 'negative'));
756
+ }
757
+ return cons;
758
+ }
759
+ /**
760
+ * ADR-PIPELINE-100 §D5: build a single loud-failure ADR record describing why
761
+ * generation failed. The pipeline emits exactly this record (and writes its
762
+ * markdown to `phase4/adrs/ADR-FAIL-001.md`) when both the LLM and the Ruflo
763
+ * paths fail to produce content. Replaces the silent SPARC-template fallback.
764
+ */
765
+ export function buildLoudFailureADR(args) {
766
+ const today = new Date().toISOString().slice(0, 10);
767
+ const lines = [];
768
+ lines.push('# ADR-FAIL-001: ADR Generation Failed');
769
+ lines.push('');
770
+ lines.push('**Status:** Failed');
771
+ lines.push(`**Date:** ${today}`);
772
+ lines.push(`**Implementation Phase:** cross-cutting`);
773
+ lines.push(`**Failure mode:** ${args.reason}`);
774
+ lines.push('');
775
+ lines.push('## What happened');
776
+ lines.push('');
777
+ lines.push('Phase 4 attempted ADR generation via the configured Claude Code transport ' +
778
+ '(streamed `claude --print` per ADR-PIPELINE-100 §D3) and any available fallback ' +
779
+ 'transports. All paths failed to produce usable markdown ADRs.');
780
+ lines.push('');
781
+ if (args.rufloFailureReason) {
782
+ lines.push(`- **Ruflo path:** ${args.rufloFailureReason}`);
1521
783
  }
1522
- if (Array.isArray(raw.references)) {
1523
- out.references = raw.references.map(String).filter(Boolean);
784
+ if (args.claudeFailureReason) {
785
+ lines.push(`- **Claude Code path:** ${args.claudeFailureReason}`);
1524
786
  }
1525
- const pt = parseTable(raw.performanceTargets);
1526
- if (pt)
1527
- out.performanceTargets = pt;
1528
- const is = parseTable(raw.implementationStatus);
1529
- if (is)
1530
- out.implementationStatus = is;
1531
- if (Array.isArray(raw.appendices)) {
1532
- out.appendices = raw.appendices
1533
- .filter((a) => a !== null && typeof a === 'object')
1534
- .map(a => ({
1535
- title: String(a['title'] ?? ''),
1536
- body: String(a['body'] ?? ''),
1537
- }))
1538
- .filter(a => a.title && a.body);
787
+ if (args.apiFailureReason) {
788
+ lines.push(`- **Direct Anthropic API path:** ${args.apiFailureReason}`);
1539
789
  }
1540
- if (Array.isArray(raw.components)) {
1541
- out.components = raw.components
1542
- .filter((c) => c !== null && typeof c === 'object')
1543
- .map(c => {
1544
- const compOut = {
1545
- name: String(c['name'] ?? ''),
1546
- description: String(c['description'] ?? ''),
1547
- };
1548
- const ce = c['codeExample'];
1549
- if (ce && typeof ce === 'object') {
1550
- const lang = String(ce['language'] ?? '');
1551
- const code = String(ce['code'] ?? '');
1552
- if (code.trim())
1553
- compOut.codeExample = { language: lang, code };
1554
- }
1555
- const ct = parseTable(c['configTable']);
1556
- if (ct)
1557
- compOut.configTable = ct;
1558
- if (Array.isArray(c['performanceCharacteristics'])) {
1559
- compOut.performanceCharacteristics = c['performanceCharacteristics'].map(String).filter(Boolean);
1560
- }
1561
- if (Array.isArray(c['securityNotes'])) {
1562
- compOut.securityNotes = c['securityNotes'].map(String).filter(Boolean);
1563
- }
1564
- return compOut;
1565
- })
1566
- .filter(c => c.name && c.description);
790
+ lines.push('');
791
+ lines.push('## Pipeline state');
792
+ lines.push('');
793
+ if (args.phase1Status)
794
+ lines.push(`- **Phase 1 artifacts:** ${args.phase1Status}`);
795
+ if (typeof args.dossierItemCount === 'number')
796
+ lines.push(`- **Phase 2 dossier:** ${args.dossierItemCount} items`);
797
+ if (args.sparcStatus)
798
+ lines.push(`- **Phase 3 SPARC:** ${args.sparcStatus}`);
799
+ if (args.simulationId)
800
+ lines.push(`- **Simulation ID:** ${args.simulationId}`);
801
+ lines.push('');
802
+ lines.push('## Why this is the artifact (not template boilerplate)');
803
+ lines.push('');
804
+ lines.push('Per ADR-PIPELINE-100 §D5, this run emits a single loud-failure ADR ' +
805
+ 'instead of falling back to template-substituted ADRs. The template path ' +
806
+ 'has been classified by the user as canned garbage that cannot meet the ' +
807
+ 'quality bar regardless of input domain. Loud failure replaces silent ' +
808
+ 'fallback so the regression is visible at the artifact level rather than ' +
809
+ 'hidden behind a successful exit code.');
810
+ lines.push('');
811
+ lines.push('## Next steps');
812
+ lines.push('');
813
+ lines.push('Implementation prompts (Phase 5a) and code generation (Phase 5b) cannot proceed coherently from this run.');
814
+ lines.push('To recover:');
815
+ lines.push('');
816
+ lines.push('1. Re-run with verbose Claude Code logging enabled: `AGENTICS_CLAUDE_DEBUG=1`');
817
+ lines.push('2. Inspect `phase4/claude-stderr.log` for the live transport failure reason');
818
+ lines.push('3. If Claude Code is unreachable, verify the subscription is active and `claude --print` works standalone');
819
+ lines.push('4. If timeouts persist, increase `AGENTICS_ADR_TIMEOUT_MS` (default 1800000 / 30 min)');
820
+ lines.push('');
821
+ if (args.query) {
822
+ lines.push('## Original query');
823
+ lines.push('');
824
+ lines.push('```');
825
+ lines.push(args.query.slice(0, 4000));
826
+ lines.push('```');
827
+ lines.push('');
1567
828
  }
1568
- return out;
1569
- }
1570
- /** Parse a {headers, rows} table-shaped value into a strict shape, or undefined. */
1571
- function parseTable(v) {
1572
- if (!v || typeof v !== 'object')
1573
- return undefined;
1574
- const t = v;
1575
- if (!Array.isArray(t['headers']) || !Array.isArray(t['rows']))
1576
- return undefined;
1577
- const headers = t['headers'].map(String);
1578
- const rows = t['rows']
1579
- .filter(Array.isArray)
1580
- .map(row => row.map(String));
1581
- if (headers.length === 0 || rows.length === 0)
1582
- return undefined;
1583
- return { headers, rows };
829
+ const rawMarkdown = lines.join('\n') + '\n';
830
+ return {
831
+ id: 'ADR-FAIL-001',
832
+ title: 'ADR Generation Failed',
833
+ status: 'failed',
834
+ date: today,
835
+ context: args.reason,
836
+ decision: 'Emit failure ADR rather than silent template boilerplate.',
837
+ alternatives: [
838
+ makeAlt('Loud-failure ADR (selected)', 'Surface the regression at the artifact level so users can see the failure', false),
839
+ makeAlt('Silent template fallback (rejected)', 'Hides the regression behind a successful exit code; produces canned output that fails the quality bar', true),
840
+ ],
841
+ consequences: [
842
+ makeCons('Pipeline failures are visible to the user immediately', 'positive'),
843
+ makeCons('Downstream phases (Phase 5a/b) cannot proceed coherently from this run', 'negative'),
844
+ ],
845
+ sparcSectionRefs: [],
846
+ researchDossierItemRefs: [],
847
+ simulationId: args.simulationId,
848
+ rawMarkdown,
849
+ phaseTags: ['cross-cutting'],
850
+ };
1584
851
  }
1585
852
  /**
1586
853
  * Generate ADRs via direct Anthropic API call.
1587
854
  * Used when Claude Code runner is unavailable (MCP mode).
1588
855
  * Requires ANTHROPIC_API_KEY in env or ~/.agentics/credentials.json.
1589
856
  */
1590
- function tryGenerateADRsViaAPI(sparc, dossier, query) {
1591
- // Synchronous API key check — getAnthropicApiKey is async but we need sync here.
1592
- // Check env var directly (most common path).
857
+ async function tryGenerateADRsViaAPI(sparc, dossier, query) {
1593
858
  const apiKey = process.env['ANTHROPIC_API_KEY'] ?? null;
1594
859
  if (!apiKey) {
1595
860
  process.stderr.write('[adr-generator] No ANTHROPIC_API_KEY — cannot call API directly\n');
1596
861
  return null;
1597
862
  }
1598
863
  try {
1599
- const prompt = buildADRPrompt(sparc, dossier, query);
864
+ const delimiter = newDocBreakDelimiter();
865
+ const prompt = buildADRPrompt(sparc, dossier, query, delimiter);
1600
866
  const startMs = Date.now();
1601
- // Synchronous HTTP via child_process (Node fetch is async-only)
1602
- // execFileSync imported at top of file
1603
- const curlArgs = [
1604
- '-s', '--max-time', '90',
1605
- '-X', 'POST',
1606
- 'https://api.anthropic.com/v1/messages',
1607
- '-H', 'Content-Type: application/json',
1608
- '-H', `x-api-key: ${apiKey}`,
1609
- '-H', 'anthropic-version: 2023-06-01',
1610
- '-d', JSON.stringify({
1611
- model: process.env['AGENTICS_ADR_MODEL'] || 'claude-sonnet-4-20250514',
1612
- // ADR-PIPELINE-101 rich ADRs with diagrams + components + appendices
1613
- // need substantially more output tokens than the pre-101 thin format.
1614
- max_tokens: 32768,
1615
- messages: [{ role: 'user', content: prompt }],
1616
- }),
1617
- ];
1618
- const rawResponse = execFileSync('curl', curlArgs, {
1619
- encoding: 'utf-8',
1620
- timeout: 360_000,
1621
- maxBuffer: 32 * 1024 * 1024,
1622
- });
867
+ const timeoutMs = parseAdrTimeoutMs();
868
+ // ADR-PIPELINE-100 §D3: async fetch with AbortController for timeout.
869
+ // max_tokens widened to 32768 because markdown ADRs are substantially
870
+ // larger than the old JSON form.
871
+ const ctl = new AbortController();
872
+ const timer = setTimeout(() => ctl.abort('adr-timeout'), timeoutMs);
873
+ const heartbeatMs = parseInt(process.env['AGENTICS_ADR_HEARTBEAT_MS'] || '', 10) || 10_000;
874
+ const heartbeat = setInterval(() => {
875
+ const elapsedSec = Math.floor((Date.now() - startMs) / 1000);
876
+ process.stderr.write(`[agentics] ⏳ Anthropic API call in flight — ${elapsedSec}s elapsed\n`);
877
+ }, heartbeatMs);
878
+ let rawResponse = '';
879
+ try {
880
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
881
+ method: 'POST',
882
+ headers: {
883
+ 'Content-Type': 'application/json',
884
+ 'x-api-key': apiKey,
885
+ 'anthropic-version': '2023-06-01',
886
+ },
887
+ body: JSON.stringify({
888
+ model: process.env['AGENTICS_ADR_MODEL'] || 'claude-sonnet-4-20250514',
889
+ max_tokens: 32768,
890
+ messages: [{ role: 'user', content: prompt }],
891
+ }),
892
+ signal: ctl.signal,
893
+ });
894
+ rawResponse = await res.text();
895
+ }
896
+ finally {
897
+ clearTimeout(timer);
898
+ clearInterval(heartbeat);
899
+ }
1623
900
  const response = JSON.parse(rawResponse);
1624
901
  if (response.error) {
1625
902
  process.stderr.write(`[adr-generator] API error: ${response.error.message}\n`);
@@ -1630,13 +907,13 @@ function tryGenerateADRsViaAPI(sparc, dossier, query) {
1630
907
  process.stderr.write('[adr-generator] API returned no text content\n');
1631
908
  return null;
1632
909
  }
1633
- const adrs = parseRawADRs(textBlock.text, dossier);
910
+ const adrs = parseMarkdownADRs(textBlock.text, delimiter, dossier);
1634
911
  if (!adrs || adrs.length === 0) {
1635
- process.stderr.write('[adr-generator] API response did not contain valid ADR JSON\n');
912
+ process.stderr.write('[adr-generator] API response did not contain parseable markdown ADRs\n');
1636
913
  return null;
1637
914
  }
1638
915
  const durationMs = Date.now() - startMs;
1639
- process.stderr.write(`[adr-generator] API-generated ${adrs.length} domain-specific ADRs in ${durationMs}ms\n`);
916
+ process.stderr.write(`[adr-generator] API-generated ${adrs.length} domain-specific ADRs in ${durationMs}ms (markdown-direct, ADR-PIPELINE-100)\n`);
1640
917
  return adrs;
1641
918
  }
1642
919
  catch (err) {
@@ -1678,10 +955,43 @@ export async function generateADRs(context) {
1678
955
  if (techStack.erp) {
1679
956
  process.stderr.write(`[adr-generator] Detected ERP: ${techStack.erp.name} (${techStack.erp.slug})\n`);
1680
957
  }
1681
- // 3-tier ADR generation: claude --print Anthropic API → SPARC-derived
1682
- let adrs = (query ? tryGenerateLLMADRs(sparc, dossier, query, context.phase2Dir) : null)
1683
- ?? (query ? tryGenerateADRsViaAPI(sparc, dossier, query) : null)
1684
- ?? buildADRsFromSPARC(sparc, dossier, query);
958
+ // ADR-PIPELINE-100: LLM-only chain with loud failure. No silent template
959
+ // fallback. Order: claude --print (subscription-backed) Anthropic API
960
+ // (ANTHROPIC_API_KEY) loud-failure ADR.
961
+ let adrs;
962
+ if (!query) {
963
+ adrs = [buildLoudFailureADR({
964
+ reason: 'No query string available — Phase 1 manifest/scenario did not surface the user prompt.',
965
+ phase1Status: 'missing-query',
966
+ dossierItemCount: dossier.items.length,
967
+ sparcStatus: (sparc.architecture?.services?.length ?? 0) > 0 ? 'present' : 'missing-services',
968
+ simulationId,
969
+ })];
970
+ }
971
+ else {
972
+ const runnerResult = await tryGenerateLLMADRs(sparc, dossier, query, context.phase2Dir);
973
+ if (runnerResult && runnerResult.length > 0) {
974
+ adrs = runnerResult;
975
+ }
976
+ else {
977
+ const apiResult = await tryGenerateADRsViaAPI(sparc, dossier, query);
978
+ if (apiResult && apiResult.length > 0) {
979
+ adrs = apiResult;
980
+ }
981
+ else {
982
+ process.stderr.write('[adr-generator] All LLM transports failed in generateADRs — emitting loud-failure ADR (ADR-PIPELINE-100 §D5)\n');
983
+ adrs = [buildLoudFailureADR({
984
+ reason: 'All LLM transports failed to return parseable markdown ADRs.',
985
+ claudeFailureReason: 'tryGenerateLLMADRs returned no parseable markdown ADRs',
986
+ apiFailureReason: 'tryGenerateADRsViaAPI returned no parseable markdown ADRs',
987
+ dossierItemCount: dossier.items.length,
988
+ sparcStatus: (sparc.architecture?.services?.length ?? 0) > 0 ? 'present' : 'missing-services',
989
+ query,
990
+ simulationId,
991
+ })];
992
+ }
993
+ }
994
+ }
1685
995
  // ADR-PIPELINE-018: Stamp simulation lineage on all ADRs
1686
996
  if (simulationId) {
1687
997
  adrs = adrs.map(adr => ({ ...adr, simulationId }));