@llm-dev-ops/agentics-cli 2.1.5 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/pipeline/auto-chain.d.ts +190 -0
  2. package/dist/pipeline/auto-chain.d.ts.map +1 -1
  3. package/dist/pipeline/auto-chain.js +1571 -72
  4. package/dist/pipeline/auto-chain.js.map +1 -1
  5. package/dist/pipeline/phase2/phases/prompt-generator.d.ts.map +1 -1
  6. package/dist/pipeline/phase2/phases/prompt-generator.js +205 -12
  7. package/dist/pipeline/phase2/phases/prompt-generator.js.map +1 -1
  8. package/dist/pipeline/phase2/schemas.d.ts +10 -10
  9. package/dist/pipeline/phase4/phases/http-server-generator.d.ts +12 -0
  10. package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
  11. package/dist/pipeline/phase4/phases/http-server-generator.js +92 -25
  12. package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
  13. package/dist/pipeline/phase4-5-pre-render/financial-model.d.ts +51 -0
  14. package/dist/pipeline/phase4-5-pre-render/financial-model.d.ts.map +1 -0
  15. package/dist/pipeline/phase4-5-pre-render/financial-model.js +118 -0
  16. package/dist/pipeline/phase4-5-pre-render/financial-model.js.map +1 -0
  17. package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.d.ts +53 -0
  18. package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.d.ts.map +1 -0
  19. package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.js +130 -0
  20. package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.js.map +1 -0
  21. package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.d.ts +47 -0
  22. package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.d.ts.map +1 -0
  23. package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.js +105 -0
  24. package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.js.map +1 -0
  25. package/dist/pipeline/phase4-5-pre-render/sector-baselines.d.ts +42 -0
  26. package/dist/pipeline/phase4-5-pre-render/sector-baselines.d.ts.map +1 -0
  27. package/dist/pipeline/phase4-5-pre-render/sector-baselines.js +117 -0
  28. package/dist/pipeline/phase4-5-pre-render/sector-baselines.js.map +1 -0
  29. package/dist/pipeline/phase5-build/phase5-build-coordinator.d.ts.map +1 -1
  30. package/dist/pipeline/phase5-build/phase5-build-coordinator.js +44 -0
  31. package/dist/pipeline/phase5-build/phase5-build-coordinator.js.map +1 -1
  32. package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts +75 -0
  33. package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts.map +1 -0
  34. package/dist/pipeline/phase5-build/phases/post-generation-validator.js +1068 -0
  35. package/dist/pipeline/phase5-build/phases/post-generation-validator.js.map +1 -0
  36. package/dist/pipeline/phase5-build/types.d.ts +1 -1
  37. package/dist/pipeline/phase5-build/types.d.ts.map +1 -1
  38. package/dist/pipeline/types.d.ts +87 -0
  39. package/dist/pipeline/types.d.ts.map +1 -1
  40. package/dist/pipeline/types.js +51 -1
  41. package/dist/pipeline/types.js.map +1 -1
  42. package/dist/synthesis/consensus-svg.d.ts +19 -0
  43. package/dist/synthesis/consensus-svg.d.ts.map +1 -0
  44. package/dist/synthesis/consensus-svg.js +95 -0
  45. package/dist/synthesis/consensus-svg.js.map +1 -0
  46. package/dist/synthesis/consensus-tiers.d.ts +99 -0
  47. package/dist/synthesis/consensus-tiers.d.ts.map +1 -0
  48. package/dist/synthesis/consensus-tiers.js +285 -0
  49. package/dist/synthesis/consensus-tiers.js.map +1 -0
  50. package/dist/synthesis/domain-labor-classifier.d.ts +101 -0
  51. package/dist/synthesis/domain-labor-classifier.d.ts.map +1 -0
  52. package/dist/synthesis/domain-labor-classifier.js +312 -0
  53. package/dist/synthesis/domain-labor-classifier.js.map +1 -0
  54. package/dist/synthesis/domain-unit-registry.d.ts +59 -0
  55. package/dist/synthesis/domain-unit-registry.d.ts.map +1 -0
  56. package/dist/synthesis/domain-unit-registry.js +320 -0
  57. package/dist/synthesis/domain-unit-registry.js.map +1 -0
  58. package/dist/synthesis/financial-claim-extractor.d.ts +72 -0
  59. package/dist/synthesis/financial-claim-extractor.d.ts.map +1 -0
  60. package/dist/synthesis/financial-claim-extractor.js +382 -0
  61. package/dist/synthesis/financial-claim-extractor.js.map +1 -0
  62. package/dist/synthesis/financial-consistency-rules.d.ts +70 -0
  63. package/dist/synthesis/financial-consistency-rules.d.ts.map +1 -0
  64. package/dist/synthesis/financial-consistency-rules.js +483 -0
  65. package/dist/synthesis/financial-consistency-rules.js.map +1 -0
  66. package/dist/synthesis/financial-consistency-runner.d.ts +73 -0
  67. package/dist/synthesis/financial-consistency-runner.d.ts.map +1 -0
  68. package/dist/synthesis/financial-consistency-runner.js +131 -0
  69. package/dist/synthesis/financial-consistency-runner.js.map +1 -0
  70. package/dist/synthesis/forbidden-spin-phrases.d.ts +32 -0
  71. package/dist/synthesis/forbidden-spin-phrases.d.ts.map +1 -0
  72. package/dist/synthesis/forbidden-spin-phrases.js +84 -0
  73. package/dist/synthesis/forbidden-spin-phrases.js.map +1 -0
  74. package/dist/synthesis/phase-gate-thresholds.d.ts +30 -0
  75. package/dist/synthesis/phase-gate-thresholds.d.ts.map +1 -0
  76. package/dist/synthesis/phase-gate-thresholds.js +34 -0
  77. package/dist/synthesis/phase-gate-thresholds.js.map +1 -0
  78. package/dist/synthesis/prompts/index.d.ts.map +1 -1
  79. package/dist/synthesis/prompts/index.js +22 -0
  80. package/dist/synthesis/prompts/index.js.map +1 -1
  81. package/dist/synthesis/roadmap-dates.d.ts +72 -0
  82. package/dist/synthesis/roadmap-dates.d.ts.map +1 -0
  83. package/dist/synthesis/roadmap-dates.js +203 -0
  84. package/dist/synthesis/roadmap-dates.js.map +1 -0
  85. package/dist/synthesis/simulation-artifact-generator.d.ts.map +1 -1
  86. package/dist/synthesis/simulation-artifact-generator.js +135 -1
  87. package/dist/synthesis/simulation-artifact-generator.js.map +1 -1
  88. package/dist/synthesis/simulation-renderers.d.ts +105 -2
  89. package/dist/synthesis/simulation-renderers.d.ts.map +1 -1
  90. package/dist/synthesis/simulation-renderers.js +1192 -123
  91. package/dist/synthesis/simulation-renderers.js.map +1 -1
  92. package/dist/synthesis/unit-economics-loader.d.ts +71 -0
  93. package/dist/synthesis/unit-economics-loader.d.ts.map +1 -0
  94. package/dist/synthesis/unit-economics-loader.js +200 -0
  95. package/dist/synthesis/unit-economics-loader.js.map +1 -0
  96. package/package.json +1 -1
@@ -87,6 +87,1307 @@ function copyDirRecursive(src, dest) {
87
87
  * Called after every phase so even if later phases fail, earlier artifacts
88
88
  * are still available.
89
89
  */
90
+ /**
91
+ * ADR-PIPELINE-074: scaffold body for `src/logger.ts`.
92
+ *
93
+ * Uses `node:async_hooks` AsyncLocalStorage so concurrent Express/Hono
94
+ * requests NEVER cross-contaminate log lines. The legacy
95
+ * `let currentCorrelationId` module-level mutable has been removed; any
96
+ * generator that reintroduces it is caught by PGV-016.
97
+ *
98
+ * Exports:
99
+ * - `Logger` interface with info/warn/error/child
100
+ * - `createLogger(service)` factory
101
+ * - `runWithCorrelation(id, fn)` — wraps an async scope with the ID
102
+ * - `getCorrelationId()` — returns the ID for the current async scope
103
+ * - `setCorrelationId(id)` — legacy shim; mutates the current store
104
+ */
105
+ export const LOGGER_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-074)
106
+ // Correlation IDs flow through an AsyncLocalStorage so concurrent requests
107
+ // never cross-contaminate log lines. Import runWithCorrelation + createLogger
108
+ // from here; NEVER reach for a module-level mutable to store the ID.
109
+ import { AsyncLocalStorage } from 'node:async_hooks';
110
+
111
+ export interface Logger {
112
+ info(event: string, data?: Record<string, unknown>): void;
113
+ warn(event: string, data?: Record<string, unknown>): void;
114
+ error(event: string, error: Error, data?: Record<string, unknown>): void;
115
+ child(bindings: Record<string, unknown>): Logger;
116
+ }
117
+
118
+ interface LoggerContext {
119
+ correlationId?: string;
120
+ bindings: Record<string, unknown>;
121
+ }
122
+
123
+ const storage = new AsyncLocalStorage<LoggerContext>();
124
+
125
+ /**
126
+ * Run \`fn\` with \`correlationId\` installed in the current async scope.
127
+ * Every log line emitted inside \`fn\` and its async descendants will carry
128
+ * this ID, and it will NOT leak to other concurrent requests.
129
+ */
130
+ export function runWithCorrelation<T>(correlationId: string, fn: () => T): T {
131
+ const parent = storage.getStore();
132
+ const ctx: LoggerContext = {
133
+ correlationId,
134
+ bindings: parent?.bindings ?? {},
135
+ };
136
+ return storage.run(ctx, fn);
137
+ }
138
+
139
+ /** Returns the correlation ID for the current async scope, if any. */
140
+ export function getCorrelationId(): string | undefined {
141
+ return storage.getStore()?.correlationId;
142
+ }
143
+
144
+ /**
145
+ * Back-compat shim — deprecated. Retained for legacy callers, but
146
+ * SHOULD NOT be used inside request handlers. Use runWithCorrelation.
147
+ * Under AsyncLocalStorage, this mutates the current store in place;
148
+ * outside an async scope it is a silent no-op.
149
+ */
150
+ export function setCorrelationId(id: string | undefined): void {
151
+ const ctx = storage.getStore();
152
+ if (ctx) ctx.correlationId = id;
153
+ }
154
+
155
+ export function createLogger(service: string): Logger {
156
+ const build = (bindings: Record<string, unknown>): Logger => {
157
+ const emit = (level: string, event: string, extra?: Record<string, unknown>): void => {
158
+ const ctx = storage.getStore();
159
+ const entry = {
160
+ timestamp: new Date().toISOString(),
161
+ level,
162
+ service,
163
+ event,
164
+ ...(ctx?.correlationId ? { correlationId: ctx.correlationId } : {}),
165
+ ...bindings,
166
+ ...(ctx?.bindings ?? {}),
167
+ ...extra,
168
+ };
169
+ process.stderr.write(JSON.stringify(entry) + '\\n');
170
+ };
171
+ return {
172
+ info: (event, data) => emit('info', event, data),
173
+ warn: (event, data) => emit('warn', event, data),
174
+ error: (event, err, data) => emit('error', event, {
175
+ error: err.message,
176
+ stack: err.stack,
177
+ ...data,
178
+ }),
179
+ child: (childBindings) => build({ ...bindings, ...childBindings }),
180
+ };
181
+ };
182
+ return build({});
183
+ }
184
+ `;
185
+ /**
186
+ * ADR-PIPELINE-074: concurrency-correctness test emitted alongside
187
+ * `src/logger.ts` in every generated project. Fails loudly if
188
+ * AsyncLocalStorage wiring regresses.
189
+ */
190
+ export const LOGGER_CONCURRENCY_TEST_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-074)
191
+ // Proves correlation IDs do NOT leak across concurrent async tasks.
192
+ // Works with vitest and jest — both expose describe/it/expect as globals.
193
+ /* eslint-disable @typescript-eslint/no-explicit-any */
194
+ import { describe, it, expect } from 'vitest';
195
+ import { runWithCorrelation, getCorrelationId } from './logger.js';
196
+
197
+ describe('logger correlation-id async scoping (ADR-PIPELINE-074)', () => {
198
+ it('does not leak correlation IDs across concurrent async tasks', async () => {
199
+ const results: Array<{ requested: string; observed: string | undefined }> = [];
200
+ const tasks: Promise<void>[] = [];
201
+ for (let i = 0; i < 50; i++) {
202
+ const id = \`req-\${i}\`;
203
+ tasks.push(
204
+ runWithCorrelation(id, async () => {
205
+ await new Promise((r) => setTimeout(r, Math.random() * 10));
206
+ results.push({ requested: id, observed: getCorrelationId() });
207
+ }),
208
+ );
209
+ }
210
+ await Promise.all(tasks);
211
+ for (const r of results) {
212
+ expect(r.observed).toBe(r.requested);
213
+ }
214
+ });
215
+ });
216
+ `;
217
+ /**
218
+ * ADR-PIPELINE-068: scaffold body for `src/simulation-lineage.ts`.
219
+ *
220
+ * Exported so unit tests can write the file to a temp directory and
221
+ * exercise the runtime behavior (env override, manifest walk, fallback)
222
+ * without having to spin up the full pipeline.
223
+ *
224
+ * Invariants enforced by tests:
225
+ * - Uses `readFileSync` + `fileURLToPath` (NEVER `require(`)
226
+ * - Exports `loadSimulationLineage`, `requireSimulationLineage`,
227
+ * `formatLineageBanner`, and the legacy `loadSimulationId` alias
228
+ * - Walks up at most 6 directories from the module location and from cwd
229
+ */
230
+ export const SIMULATION_LINEAGE_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-068)
231
+ // ESM-safe simulation lineage loader. Reads .agentics/plans/manifest.json
232
+ // (or .agentics/runs/latest/manifest.json) using readFileSync — never
233
+ // CommonJS require(). Do NOT reimplement this helper; import from it.
234
+ import { readFileSync, existsSync } from 'node:fs';
235
+ import { resolve, dirname, join } from 'node:path';
236
+ import { fileURLToPath } from 'node:url';
237
+
238
+ export type SimulationLineageSource = 'env' | 'manifest' | 'fallback';
239
+
240
+ export interface SimulationLineage {
241
+ readonly simulationId: string;
242
+ readonly traceId: string;
243
+ readonly runId: string;
244
+ readonly source: SimulationLineageSource;
245
+ readonly manifestPath?: string;
246
+ }
247
+
248
+ const MANIFEST_CANDIDATES: readonly string[] = [
249
+ '.agentics/plans/manifest.json',
250
+ '.agentics/runs/latest/manifest.json',
251
+ ];
252
+
253
+ const WALK_DEPTH = 6;
254
+
255
+ /** Walk up from startDir looking for any manifest candidate path. */
256
+ function findManifest(startDir: string): string | null {
257
+ let dir = startDir;
258
+ for (let i = 0; i < WALK_DEPTH; i++) {
259
+ for (const rel of MANIFEST_CANDIDATES) {
260
+ const candidate = resolve(dir, rel);
261
+ if (existsSync(candidate)) return candidate;
262
+ }
263
+ const parent = dirname(dir);
264
+ if (parent === dir) break;
265
+ dir = parent;
266
+ }
267
+ return null;
268
+ }
269
+
270
+ function moduleDir(): string {
271
+ try {
272
+ return dirname(fileURLToPath(import.meta.url));
273
+ } catch {
274
+ return process.cwd();
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Load the simulation lineage. Permissive — returns a fallback record when
280
+ * nothing is available. Callers who need strict behavior should use
281
+ * requireSimulationLineage() instead.
282
+ *
283
+ * Resolution order:
284
+ * 1. AGENTICS_SIMULATION_ID / AGENTICS_TRACE_ID environment variables
285
+ * 2. First manifest.json found walking up from this module (then cwd)
286
+ * 3. Fallback record with source='fallback'
287
+ */
288
+ export function loadSimulationLineage(): SimulationLineage {
289
+ const envSim = process.env['AGENTICS_SIMULATION_ID'];
290
+ const envTrace = process.env['AGENTICS_TRACE_ID'];
291
+ if (envSim) {
292
+ return {
293
+ simulationId: envSim,
294
+ traceId: envTrace ?? envSim,
295
+ runId: envSim,
296
+ source: 'env',
297
+ };
298
+ }
299
+
300
+ const startDirs = [moduleDir(), process.cwd()];
301
+ for (const start of startDirs) {
302
+ try {
303
+ const manifestPath = findManifest(start);
304
+ if (!manifestPath) continue;
305
+ const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record<string, unknown>;
306
+ const runId = String(raw['run_id'] ?? raw['runId'] ?? '');
307
+ const simulationId = String(
308
+ raw['simulation_id'] ?? raw['simulationId'] ?? raw['execution_id'] ?? runId,
309
+ );
310
+ const traceId = String(raw['trace_id'] ?? raw['traceId'] ?? simulationId);
311
+ if (simulationId) {
312
+ return { simulationId, traceId, runId: runId || simulationId, source: 'manifest', manifestPath };
313
+ }
314
+ } catch {
315
+ // Try the next start dir. Never throw from the permissive loader.
316
+ }
317
+ }
318
+
319
+ return {
320
+ simulationId: 'sim-unknown',
321
+ traceId: 'trace-unknown',
322
+ runId: 'run-unknown',
323
+ source: 'fallback',
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Strict variant — throws ECLI-LIN-068 when no simulation lineage is
329
+ * available. Use this at startup when the service MUST carry a real
330
+ * simulation id (audit logs, ERP posts, etc.).
331
+ */
332
+ export function requireSimulationLineage(): SimulationLineage {
333
+ const lineage = loadSimulationLineage();
334
+ if (lineage.source === 'fallback') {
335
+ throw new Error(
336
+ 'ECLI-LIN-068: simulation lineage unavailable — set AGENTICS_SIMULATION_ID or place .agentics/plans/manifest.json in the project tree',
337
+ );
338
+ }
339
+ return lineage;
340
+ }
341
+
342
+ /**
343
+ * Human-readable banner printed by the demo script when the loader falls
344
+ * back. Makes the break visible instead of letting sim-unknown leak into
345
+ * downstream audit logs and ERP posts.
346
+ */
347
+ export function formatLineageBanner(lineage: SimulationLineage): string {
348
+ if (lineage.source !== 'fallback') {
349
+ return \`simulation lineage: \${lineage.simulationId} (source: \${lineage.source})\`;
350
+ }
351
+ return [
352
+ '',
353
+ '⚠️ Simulation lineage unavailable (source: fallback)',
354
+ ' All audit entries and ERP posts will carry sim-unknown.',
355
+ ' Fix: place .agentics/plans/manifest.json in the project tree,',
356
+ ' or set AGENTICS_SIMULATION_ID=<run-id> before running the demo.',
357
+ '',
358
+ ].join('\\n');
359
+ }
360
+
361
+ /** Also exported under the legacy alias some older prompts referenced. */
362
+ export const loadSimulationId = (): string => loadSimulationLineage().simulationId;
363
+ `;
364
+ /**
365
+ * ADR-PIPELINE-069: scaffold body for `src/circuit-breaker.ts`.
366
+ *
367
+ * Extracted from the legacy middleware.ts scaffold so generators can
368
+ * `import { CircuitBreaker } from './circuit-breaker.js'` without pulling
369
+ * the entire middleware module. middleware.ts now re-exports this symbol
370
+ * so existing imports keep working.
371
+ */
372
+ export const CIRCUIT_BREAKER_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-069)
373
+ // Owned by the scaffold — do NOT redeclare CircuitBreaker in generated code.
374
+ import { createLogger } from './logger.js';
375
+
376
+ /** Simple circuit breaker for external service calls. */
377
+ export class CircuitBreaker {
378
+ private failures = 0;
379
+ private state: 'closed' | 'open' | 'half-open' = 'closed';
380
+ private nextAttempt = 0;
381
+
382
+ constructor(
383
+ private readonly name: string,
384
+ private readonly threshold = 5,
385
+ private readonly cooldownMs = 30000,
386
+ private readonly logger = createLogger('circuit-breaker'),
387
+ ) {}
388
+
389
+ async call<T>(fn: () => Promise<T>): Promise<T> {
390
+ if (this.state === 'open') {
391
+ if (Date.now() < this.nextAttempt) {
392
+ throw new Error(\`Circuit breaker '\${this.name}' is OPEN — retry after \${new Date(this.nextAttempt).toISOString()}\`);
393
+ }
394
+ this.state = 'half-open';
395
+ this.logger.info('circuit.half-open', { name: this.name });
396
+ }
397
+ try {
398
+ const result = await fn();
399
+ if (this.state === 'half-open') {
400
+ this.state = 'closed';
401
+ this.failures = 0;
402
+ this.logger.info('circuit.closed', { name: this.name });
403
+ }
404
+ return result;
405
+ } catch (err) {
406
+ this.failures++;
407
+ if (this.failures >= this.threshold) {
408
+ this.state = 'open';
409
+ this.nextAttempt = Date.now() + this.cooldownMs;
410
+ this.logger.warn('circuit.open', { name: this.name, failures: this.failures, retryAt: new Date(this.nextAttempt).toISOString() });
411
+ }
412
+ throw err;
413
+ }
414
+ }
415
+
416
+ getState(): string { return this.state; }
417
+ }
418
+ `;
419
+ /**
420
+ * ADR-PIPELINE-075: scaffold body for `src/persistence/repository.ts`.
421
+ *
422
+ * Defines the `Repository<T>` interface every stateful service should
423
+ * depend on. Each entity is identified by `idOf(entity)`, validated by a
424
+ * Zod schema on read, and bound to a table name for the SQLite backend.
425
+ */
426
+ export const REPOSITORY_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-075)
427
+ // Repository interface every stateful service should depend on.
428
+ // Generators: use this instead of storing state in a Map<string, T>.
429
+ import type { ZodType } from 'zod';
430
+
431
+ export interface Repository<T> {
432
+ save(entity: T): Promise<void>;
433
+ get(id: string): Promise<T | null>;
434
+ list(filter?: Partial<T>): Promise<readonly T[]>;
435
+ delete(id: string): Promise<void>;
436
+ }
437
+
438
+ export interface RepositoryOptions<T> {
439
+ /** How to extract the primary key from an entity. */
440
+ readonly idOf: (entity: T) => string;
441
+ /** Zod schema used to re-validate rows on load (SQLite JSON columns are untrusted). */
442
+ readonly schema: ZodType<T>;
443
+ /** Table name for SQLite backend. Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/. */
444
+ readonly tableName: string;
445
+ }
446
+
447
+ /** Errors thrown by all Repository implementations. */
448
+ export class RepositoryError extends Error {
449
+ constructor(message: string, public readonly code: string) {
450
+ super(\`\${code}: \${message}\`);
451
+ this.name = 'RepositoryError';
452
+ }
453
+ }
454
+ `;
455
+ /**
456
+ * ADR-PIPELINE-075: scaffold body for `src/persistence/in-memory-repository.ts`.
457
+ *
458
+ * Test-friendly `Repository<T>` implementation backed by a Map. Validates
459
+ * on write AND read so test fixtures catch schema drift. Production
460
+ * services should swap in SqliteRepository via the composition root.
461
+ */
462
+ export const IN_MEMORY_REPO_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-075)
463
+ // In-memory Repository<T> for tests. Not for production use.
464
+ import type { Repository, RepositoryOptions } from './repository.js';
465
+
466
+ export class InMemoryRepository<T> implements Repository<T> {
467
+ private readonly rows = new Map<string, T>();
468
+ constructor(private readonly options: RepositoryOptions<T>) {}
469
+
470
+ async save(entity: T): Promise<void> {
471
+ const validated = this.options.schema.parse(entity);
472
+ this.rows.set(this.options.idOf(validated), validated);
473
+ }
474
+
475
+ async get(id: string): Promise<T | null> {
476
+ const row = this.rows.get(id);
477
+ return row === undefined ? null : row;
478
+ }
479
+
480
+ async list(filter?: Partial<T>): Promise<readonly T[]> {
481
+ const all = Array.from(this.rows.values());
482
+ if (!filter) return all;
483
+ return all.filter(row =>
484
+ Object.entries(filter).every(
485
+ ([k, v]) => (row as Record<string, unknown>)[k] === v,
486
+ ),
487
+ );
488
+ }
489
+
490
+ async delete(id: string): Promise<void> {
491
+ this.rows.delete(id);
492
+ }
493
+
494
+ /** Test helper — never call from production code. */
495
+ clear(): void {
496
+ this.rows.clear();
497
+ }
498
+
499
+ /** Test helper — returns the current row count. */
500
+ get size(): number {
501
+ return this.rows.size;
502
+ }
503
+ }
504
+ `;
505
+ /**
506
+ * ADR-PIPELINE-075: scaffold body for `src/persistence/sqlite-repository.ts`.
507
+ *
508
+ * Production `Repository<T>` backed by better-sqlite3. Stores each entity
509
+ * as a JSON column keyed by its primary key. Zod re-validates on read
510
+ * because the JSON column is untrusted at runtime.
511
+ *
512
+ * Generated projects must add better-sqlite3 + @types/better-sqlite3 to
513
+ * their package.json — the cross-cutting prompt footer reminds the coding
514
+ * agent to include these deps.
515
+ */
516
+ export const SQLITE_REPO_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-075)
517
+ // SQLite-backed Repository<T> using better-sqlite3.
518
+ // Requires: better-sqlite3 in dependencies, @types/better-sqlite3 in devDependencies.
519
+ /* eslint-disable @typescript-eslint/no-explicit-any */
520
+ import type { Repository, RepositoryOptions } from './repository.js';
521
+ import { RepositoryError } from './repository.js';
522
+
523
+ // Minimal structural type — works with better-sqlite3 without importing it
524
+ // into the scaffold (so the scaffold compiles even when the dependency
525
+ // isn't installed in the pipeline's own test environment).
526
+ export interface BetterSqliteDatabase {
527
+ exec(sql: string): void;
528
+ prepare(sql: string): {
529
+ run(...params: any[]): { changes: number; lastInsertRowid: number | bigint };
530
+ get(...params: any[]): any;
531
+ all(...params: any[]): any[];
532
+ };
533
+ }
534
+
535
+ export interface SqliteRepositoryDeps {
536
+ readonly db: BetterSqliteDatabase;
537
+ }
538
+
539
+ export class SqliteRepository<T> implements Repository<T> {
540
+ constructor(
541
+ private readonly deps: SqliteRepositoryDeps,
542
+ private readonly options: RepositoryOptions<T>,
543
+ ) {
544
+ this.assertSafeTableName();
545
+ this.deps.db.exec(\`
546
+ CREATE TABLE IF NOT EXISTS \${this.options.tableName} (
547
+ id TEXT PRIMARY KEY,
548
+ data TEXT NOT NULL,
549
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
550
+ );
551
+ \`);
552
+ }
553
+
554
+ private assertSafeTableName(): void {
555
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(this.options.tableName)) {
556
+ throw new RepositoryError(\`unsafe table name: \${this.options.tableName}\`, 'ECLI-REPO-001');
557
+ }
558
+ }
559
+
560
+ async save(entity: T): Promise<void> {
561
+ const validated = this.options.schema.parse(entity);
562
+ const id = this.options.idOf(validated);
563
+ const data = JSON.stringify(validated);
564
+ this.deps.db
565
+ .prepare(
566
+ \`INSERT INTO \${this.options.tableName} (id, data) VALUES (?, ?)
567
+ ON CONFLICT(id) DO UPDATE SET data = excluded.data,
568
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')\`,
569
+ )
570
+ .run(id, data);
571
+ }
572
+
573
+ async get(id: string): Promise<T | null> {
574
+ const row = this.deps.db
575
+ .prepare(\`SELECT data FROM \${this.options.tableName} WHERE id = ?\`)
576
+ .get(id) as { data: string } | undefined;
577
+ if (!row) return null;
578
+ return this.options.schema.parse(JSON.parse(row.data));
579
+ }
580
+
581
+ async list(filter?: Partial<T>): Promise<readonly T[]> {
582
+ const rows = this.deps.db
583
+ .prepare(\`SELECT data FROM \${this.options.tableName}\`)
584
+ .all() as Array<{ data: string }>;
585
+ const parsed = rows.map(r => this.options.schema.parse(JSON.parse(r.data)));
586
+ if (!filter) return parsed;
587
+ return parsed.filter(row =>
588
+ Object.entries(filter).every(
589
+ ([k, v]) => (row as Record<string, unknown>)[k] === v,
590
+ ),
591
+ );
592
+ }
593
+
594
+ async delete(id: string): Promise<void> {
595
+ this.deps.db.prepare(\`DELETE FROM \${this.options.tableName} WHERE id = ?\`).run(id);
596
+ }
597
+ }
598
+ `;
599
+ /**
600
+ * ADR-PIPELINE-075: scaffold body for `src/persistence/audit-repository.ts`.
601
+ *
602
+ * Append-only audit repository. NO update, NO delete — an audit entry is
603
+ * committed and lives forever. Services that need to mutate a past entry
604
+ * have to bypass the repository and talk raw SQL, which PGV and code
605
+ * review will catch.
606
+ */
607
+ export const AUDIT_REPO_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-075)
608
+ // Append-only audit repository. No update, no delete by design.
609
+ /* eslint-disable @typescript-eslint/no-explicit-any */
610
+ import type { BetterSqliteDatabase } from './sqlite-repository.js';
611
+
612
+ export interface AuditEntry {
613
+ readonly entryId: string;
614
+ readonly prevHash: string;
615
+ readonly hash: string;
616
+ readonly payload: Record<string, unknown>;
617
+ readonly actor: string;
618
+ readonly action: string;
619
+ readonly entityType: string;
620
+ readonly entityId: string;
621
+ readonly createdAt: string;
622
+ }
623
+
624
+ export interface AuditRepositoryDeps {
625
+ readonly db: BetterSqliteDatabase;
626
+ }
627
+
628
+ export class AppendOnlyAuditRepository {
629
+ constructor(private readonly deps: AuditRepositoryDeps) {
630
+ this.deps.db.exec(\`
631
+ CREATE TABLE IF NOT EXISTS audit_log (
632
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
633
+ entry_id TEXT NOT NULL UNIQUE,
634
+ prev_hash TEXT NOT NULL,
635
+ hash TEXT NOT NULL,
636
+ payload TEXT NOT NULL,
637
+ actor TEXT NOT NULL,
638
+ action TEXT NOT NULL,
639
+ entity_type TEXT NOT NULL,
640
+ entity_id TEXT NOT NULL,
641
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
642
+ );
643
+ CREATE INDEX IF NOT EXISTS idx_audit_log_entity
644
+ ON audit_log(entity_type, entity_id);
645
+ \`);
646
+ }
647
+
648
+ append(entry: AuditEntry): void {
649
+ this.deps.db
650
+ .prepare(
651
+ \`INSERT INTO audit_log
652
+ (entry_id, prev_hash, hash, payload, actor, action, entity_type, entity_id, created_at)
653
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\`,
654
+ )
655
+ .run(
656
+ entry.entryId,
657
+ entry.prevHash,
658
+ entry.hash,
659
+ JSON.stringify(entry.payload),
660
+ entry.actor,
661
+ entry.action,
662
+ entry.entityType,
663
+ entry.entityId,
664
+ entry.createdAt,
665
+ );
666
+ }
667
+
668
+ list(): readonly AuditEntry[] {
669
+ const rows = this.deps.db
670
+ .prepare(\`SELECT * FROM audit_log ORDER BY sequence ASC\`)
671
+ .all() as Array<{
672
+ entry_id: string;
673
+ prev_hash: string;
674
+ hash: string;
675
+ payload: string;
676
+ actor: string;
677
+ action: string;
678
+ entity_type: string;
679
+ entity_id: string;
680
+ created_at: string;
681
+ }>;
682
+ return rows.map(r => ({
683
+ entryId: r.entry_id,
684
+ prevHash: r.prev_hash,
685
+ hash: r.hash,
686
+ payload: JSON.parse(r.payload),
687
+ actor: r.actor,
688
+ action: r.action,
689
+ entityType: r.entity_type,
690
+ entityId: r.entity_id,
691
+ createdAt: r.created_at,
692
+ }));
693
+ }
694
+
695
+ listByEntity(entityType: string, entityId: string): readonly AuditEntry[] {
696
+ const rows = this.deps.db
697
+ .prepare(
698
+ \`SELECT * FROM audit_log
699
+ WHERE entity_type = ? AND entity_id = ?
700
+ ORDER BY sequence ASC\`,
701
+ )
702
+ .all(entityType, entityId) as Array<{
703
+ entry_id: string;
704
+ prev_hash: string;
705
+ hash: string;
706
+ payload: string;
707
+ actor: string;
708
+ action: string;
709
+ entity_type: string;
710
+ entity_id: string;
711
+ created_at: string;
712
+ }>;
713
+ return rows.map(r => ({
714
+ entryId: r.entry_id,
715
+ prevHash: r.prev_hash,
716
+ hash: r.hash,
717
+ payload: JSON.parse(r.payload),
718
+ actor: r.actor,
719
+ action: r.action,
720
+ entityType: r.entity_type,
721
+ entityId: r.entity_id,
722
+ createdAt: r.created_at,
723
+ }));
724
+ }
725
+
726
+ /** Total row count — used by verify() and reconciliation jobs. */
727
+ size(): number {
728
+ const row = this.deps.db
729
+ .prepare('SELECT COUNT(*) AS n FROM audit_log')
730
+ .get() as { n: number };
731
+ return row.n;
732
+ }
733
+ }
734
+ `;
735
+ /**
736
+ * ADR-PIPELINE-078: scaffold body for `src/persistence/canonical-json.ts`.
737
+ *
738
+ * Deterministic JSON serialization for audit hash chains and idempotency
739
+ * keys. Follows JCS (RFC 8785) closely enough for the pipeline's needs:
740
+ * object keys are sorted lexicographically at every depth; arrays preserve
741
+ * order; strings, numbers, booleans, and null use standard JSON encoding.
742
+ *
743
+ * Rejects undefined, BigInt, and circular references. Date objects should
744
+ * be serialized as ISO strings at the boundary BEFORE passing to this
745
+ * function.
746
+ */
747
+ export const CANONICAL_JSON_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-078)
748
+ // Deterministic JSON for audit hash chains. Keys sorted at every depth.
749
+
750
+ export class CanonicalJsonError extends Error {
751
+ constructor(message: string) {
752
+ super(\`ECLI-CJ-078: \${message}\`);
753
+ this.name = 'CanonicalJsonError';
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Canonicalize \`value\` into a deterministic JSON string. Keys are sorted
759
+ * at every level; arrays preserve order; primitives use standard encoding.
760
+ */
761
+ export function canonicalJson(value: unknown): string {
762
+ const visited = new WeakSet<object>();
763
+ return serialize(value, visited);
764
+ }
765
+
766
+ function serialize(value: unknown, visited: WeakSet<object>): string {
767
+ if (value === undefined) {
768
+ throw new CanonicalJsonError('undefined is not canonicalizable — pass null instead');
769
+ }
770
+ if (value === null) return 'null';
771
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
772
+ if (typeof value === 'number') {
773
+ if (!Number.isFinite(value)) {
774
+ throw new CanonicalJsonError(\`non-finite number: \${String(value)}\`);
775
+ }
776
+ return JSON.stringify(value);
777
+ }
778
+ if (typeof value === 'string') {
779
+ return JSON.stringify(value);
780
+ }
781
+ if (typeof value === 'bigint') {
782
+ throw new CanonicalJsonError('BigInt is not canonicalizable — convert to string at the boundary');
783
+ }
784
+ if (Array.isArray(value)) {
785
+ if (visited.has(value)) throw new CanonicalJsonError('circular reference in array');
786
+ visited.add(value);
787
+ const parts = value.map(item => serialize(item, visited));
788
+ visited.delete(value);
789
+ return '[' + parts.join(',') + ']';
790
+ }
791
+ if (typeof value === 'object') {
792
+ if (visited.has(value as object)) throw new CanonicalJsonError('circular reference in object');
793
+ visited.add(value as object);
794
+ const record = value as Record<string, unknown>;
795
+ const keys = Object.keys(record).sort();
796
+ const pairs: string[] = [];
797
+ for (const key of keys) {
798
+ const v = record[key];
799
+ if (v === undefined) continue;
800
+ pairs.push(JSON.stringify(key) + ':' + serialize(v, visited));
801
+ }
802
+ visited.delete(value as object);
803
+ return '{' + pairs.join(',') + '}';
804
+ }
805
+ throw new CanonicalJsonError(\`unsupported value type: \${typeof value}\`);
806
+ }
807
+ `;
808
+ /**
809
+ * ADR-PIPELINE-078: scaffold body for `src/persistence/audit-hash.ts`.
810
+ *
811
+ * Wraps `canonicalJson` with audit-log-specific conventions. Every
812
+ * generated `AuditService.append()` method should call `hashAuditEntry`
813
+ * rather than rolling its own `JSON.stringify + createHash` chain.
814
+ */
815
+ export const AUDIT_HASH_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-078)
816
+ // Audit hash-chain helper. Import hashAuditEntry instead of hand-rolling
817
+ // createHash('sha256').update(JSON.stringify(...)).
818
+ import { createHash } from 'node:crypto';
819
+ import { canonicalJson } from './canonical-json.js';
820
+
821
+ export interface HashChainInput {
822
+ readonly prevHash: string;
823
+ readonly entryId: string;
824
+ readonly actor: string;
825
+ readonly action: string;
826
+ readonly entityType: string;
827
+ readonly entityId: string;
828
+ readonly payload: unknown;
829
+ readonly createdAt: string;
830
+ }
831
+
832
+ /**
833
+ * Deterministically hash an audit entry's content + its link to the
834
+ * previous entry. Payload keys are sorted at every depth so mutations
835
+ * at any nesting level change the hash.
836
+ */
837
+ export function hashAuditEntry(input: HashChainInput): string {
838
+ const body = canonicalJson({
839
+ prevHash: input.prevHash,
840
+ entryId: input.entryId,
841
+ actor: input.actor,
842
+ action: input.action,
843
+ entityType: input.entityType,
844
+ entityId: input.entityId,
845
+ payload: input.payload,
846
+ createdAt: input.createdAt,
847
+ });
848
+ return createHash('sha256').update(body).digest('hex');
849
+ }
850
+ `;
851
+ /**
852
+ * ADR-PIPELINE-077: scaffold body for `src/erp/schema-provenance.ts`.
853
+ *
854
+ * Every generated ERP adapter file (schema.ts, <system>-adapter.ts, etc.)
855
+ * must export an `ERP_SCHEMA_PROVENANCE` constant of type
856
+ * `ErpSchemaProvenance` declaring whether the schema was invented,
857
+ * pulled from a catalog, or validated by an SME. Under
858
+ * `AGENTICS_ERP_STRICT=true`, `assertErpProvenanceOrFail` refuses to
859
+ * start when source='invented'. PGV-020 enforces presence, PGV-021
860
+ * enforces reviewer + catalog_version on validated entries.
861
+ */
862
+ export const ERP_SCHEMA_PROVENANCE_SCAFFOLD = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-077)
863
+ // Provenance tag for generated ERP schemas. MANDATORY on every file
864
+ // under src/erp/ that exports Zod schemas targeting an external ERP.
865
+
866
+ export type ErpProvenanceSource = 'invented' | 'catalog' | 'validated';
867
+
868
+ export interface ErpSchemaProvenance {
869
+ /** Target ERP system — e.g. 'Ramco Aviation', 'Oracle OPERA', 'SAP S/4HANA'. */
870
+ readonly erp_system: string;
871
+ /** Specific module the schema targets — 'Flight Catering Order'. */
872
+ readonly module: string;
873
+ /** Validation stage: invented (no review), catalog (fields match), validated (SME-reviewed). */
874
+ readonly source: ErpProvenanceSource;
875
+ /** Version identifier of the catalog source, when known. */
876
+ readonly catalog_version: string | null;
877
+ /** ISO timestamp of SME review when source='validated'. */
878
+ readonly validated_at: string | null;
879
+ /** Reviewer name + role when source='validated'. */
880
+ readonly reviewer: { name: string; role: string } | null;
881
+ /** Free-text notes visible in startup logs and generated README. */
882
+ readonly notes: string;
883
+ }
884
+
885
+ /**
886
+ * Assert an ERP adapter is allowed to run in the current environment.
887
+ * Under AGENTICS_ERP_STRICT=true, source='invented' throws ECLI-ERP-077
888
+ * and the process exits with code 77.
889
+ */
890
+ export function assertErpProvenanceOrFail(
891
+ provenance: ErpSchemaProvenance,
892
+ env: NodeJS.ProcessEnv = process.env,
893
+ ): void {
894
+ const strict = env['AGENTICS_ERP_STRICT'] === 'true';
895
+ if (strict && provenance.source === 'invented') {
896
+ throw new Error(
897
+ \`ECLI-ERP-077: ERP adapter for \${provenance.erp_system}/\${provenance.module} has source='invented' \` +
898
+ \`and AGENTICS_ERP_STRICT=true. Pilot deployment requires an SME review — set source='validated' \` +
899
+ \`and provide reviewer + catalog_version before enabling strict mode.\`,
900
+ );
901
+ }
902
+ }
903
+
904
+ /** Console-friendly banner for startup logs + demo output. */
905
+ export function formatProvenanceBanner(provenance: ErpSchemaProvenance): string {
906
+ const icon = provenance.source === 'validated' ? '✅' :
907
+ provenance.source === 'catalog' ? '🟡' :
908
+ '⚠️ ';
909
+ const lines: string[] = [
910
+ \`\${icon} ERP SCHEMA PROVENANCE — \${provenance.erp_system} / \${provenance.module}\`,
911
+ \` source: \${provenance.source}\`,
912
+ ];
913
+ if (provenance.catalog_version) lines.push(\` catalog: \${provenance.catalog_version}\`);
914
+ if (provenance.validated_at) lines.push(\` validated: \${provenance.validated_at}\`);
915
+ if (provenance.reviewer) lines.push(\` reviewer: \${provenance.reviewer.name} (\${provenance.reviewer.role})\`);
916
+ if (provenance.source === 'invented') {
917
+ lines.push(
918
+ ' ',
919
+ ' WARNING: This schema has not been validated against a real ERP document catalog.',
920
+ ' Pilot deployment requires an SME review before the first live call.',
921
+ " Set AGENTICS_ERP_STRICT=true in production to block runs until source='validated'.",
922
+ );
923
+ }
924
+ return lines.join('\\n');
925
+ }
926
+
927
+ /** Convenience wrapper — prints the banner to stderr. */
928
+ export function printProvenanceBanner(provenance: ErpSchemaProvenance): void {
929
+ process.stderr.write(formatProvenanceBanner(provenance) + '\\n');
930
+ }
931
+ `;
932
+ /**
933
+ * ADR-PIPELINE-076: scaffold body for `src/api/base-app.ts` (Hono variant).
934
+ *
935
+ * Ships a wire-complete base app so the coding agent never writes
936
+ * middleware/metrics/health plumbing from scratch. Generators extend
937
+ * via `createBaseApp({...}).route('/api/<domain>', yourRouter)` instead
938
+ * of rebuilding.
939
+ *
940
+ * Every generated project that imports `createBaseApp` gets:
941
+ * - correlation middleware (AsyncLocalStorage-scoped, ADR-074)
942
+ * - request logging with duration histogram
943
+ * - GET /health/live (process up check)
944
+ * - GET /health/ready (every readiness check passes)
945
+ * - GET /metrics (Prometheus text, ADR-051)
946
+ * - structured error handler (last)
947
+ */
948
+ export const BASE_APP_SCAFFOLD_HONO = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-076)
949
+ // Wire-complete Hono app base. DO NOT rewrite — extend via
950
+ // \`createBaseApp(deps).route('/api/...', yourRouter)\` from your routes module.
951
+ import { Hono } from 'hono';
952
+ import { randomUUID } from 'node:crypto';
953
+ import { runWithCorrelation, createLogger } from '../logger.js';
954
+ import { incrementCounter, recordHistogram, metricsHandler } from '../middleware.js';
955
+
956
+ export interface HealthCheck {
957
+ readonly name: string;
958
+ readonly check: () => Promise<{ ok: boolean; detail?: string }>;
959
+ }
960
+
961
+ export interface BaseAppDeps {
962
+ readonly serviceName: string;
963
+ readonly version: string;
964
+ readonly readiness: readonly HealthCheck[];
965
+ }
966
+
967
+ export interface BaseAppContextVariables {
968
+ correlationId: string;
969
+ startedAt: number;
970
+ }
971
+
972
+ const baseLogger = createLogger('base-app');
973
+
974
+ /**
975
+ * Build the wire-complete base app.
976
+ *
977
+ * Returned Hono instance already has:
978
+ * - correlation ID middleware (AsyncLocalStorage-scoped)
979
+ * - request logging with duration histogram
980
+ * - GET /health/live process-up check
981
+ * - GET /health/ready runs every registered check
982
+ * - GET /metrics Prometheus text format
983
+ * - structured error handler
984
+ *
985
+ * Add domain routes with \`.route('/api/<domain>', yourRouter)\`.
986
+ */
987
+ export function createBaseApp(
988
+ deps: BaseAppDeps,
989
+ ): Hono<{ Variables: BaseAppContextVariables }> {
990
+ const app = new Hono<{ Variables: BaseAppContextVariables }>();
991
+
992
+ // Correlation ID — MUST be first so every downstream log carries it
993
+ app.use('*', async (c, next) => {
994
+ const id = c.req.header('x-correlation-id') ?? randomUUID();
995
+ c.set('correlationId', id);
996
+ c.set('startedAt', Date.now());
997
+ c.header('X-Correlation-Id', id);
998
+ await runWithCorrelation(id, async () => {
999
+ await next();
1000
+ });
1001
+ });
1002
+
1003
+ // Request logging + metrics
1004
+ app.use('*', async (c, next) => {
1005
+ await next();
1006
+ const durationMs = Date.now() - c.get('startedAt');
1007
+ const status = c.res.status;
1008
+ baseLogger.info('http.request', {
1009
+ method: c.req.method,
1010
+ path: c.req.path,
1011
+ status,
1012
+ durationMs,
1013
+ });
1014
+ incrementCounter('http_requests_total', {
1015
+ method: c.req.method,
1016
+ status: String(status),
1017
+ });
1018
+ recordHistogram('http_request_duration_ms', durationMs, {
1019
+ method: c.req.method,
1020
+ });
1021
+ });
1022
+
1023
+ // Liveness — always returns OK if the process is running
1024
+ app.get('/health/live', (c) => {
1025
+ return c.json({
1026
+ status: 'ok',
1027
+ service: deps.serviceName,
1028
+ version: deps.version,
1029
+ uptime_sec: Math.round(process.uptime()),
1030
+ });
1031
+ });
1032
+
1033
+ // Readiness — runs every registered check
1034
+ app.get('/health/ready', async (c) => {
1035
+ const results: Array<{ name: string; ok: boolean; detail?: string }> = [];
1036
+ for (const check of deps.readiness) {
1037
+ try {
1038
+ const r = await check.check();
1039
+ results.push({ name: check.name, ok: r.ok, ...(r.detail ? { detail: r.detail } : {}) });
1040
+ } catch (err) {
1041
+ results.push({
1042
+ name: check.name,
1043
+ ok: false,
1044
+ detail: err instanceof Error ? err.message : String(err),
1045
+ });
1046
+ }
1047
+ }
1048
+ const allOk = results.every((r) => r.ok);
1049
+ return c.json(
1050
+ { status: allOk ? 'ready' : 'not-ready', checks: results },
1051
+ allOk ? 200 : 503,
1052
+ );
1053
+ });
1054
+
1055
+ // Metrics — Prometheus text format
1056
+ app.get('/metrics', (c) => {
1057
+ return c.text(metricsHandler(), 200, {
1058
+ 'Content-Type': 'text/plain; version=0.0.4',
1059
+ });
1060
+ });
1061
+
1062
+ // Error handler — last, so it catches everything above
1063
+ app.onError((err, c) => {
1064
+ baseLogger.error('http.error', err as Error, {
1065
+ method: c.req.method,
1066
+ path: c.req.path,
1067
+ });
1068
+ const status = (err as { httpStatus?: number }).httpStatus ?? 500;
1069
+ return c.json(
1070
+ {
1071
+ error: {
1072
+ message: err.message,
1073
+ code: (err as { code?: string }).code ?? 'INTERNAL_ERROR',
1074
+ },
1075
+ },
1076
+ status as 500,
1077
+ );
1078
+ });
1079
+
1080
+ return app;
1081
+ }
1082
+ `;
1083
+ /**
1084
+ * ADR-PIPELINE-076: scaffold body for `src/api/base-app.ts` (Express variant).
1085
+ *
1086
+ * Used when the generator detects Express in Phase 4. Same guarantees as
1087
+ * the Hono variant — wires correlation / logging / liveness / readiness
1088
+ * / metrics / error handler in the correct order.
1089
+ */
1090
+ export const BASE_APP_SCAFFOLD_EXPRESS = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-076)
1091
+ // Wire-complete Express app base. DO NOT rewrite — mount your routes
1092
+ // onto the returned \`app\` instance instead.
1093
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1094
+ import express from 'express';
1095
+ import { randomUUID } from 'node:crypto';
1096
+ import { runWithCorrelation, createLogger } from '../logger.js';
1097
+ import { incrementCounter, recordHistogram, metricsHandlerExpress } from '../middleware.js';
1098
+
1099
+ export interface HealthCheck {
1100
+ readonly name: string;
1101
+ readonly check: () => Promise<{ ok: boolean; detail?: string }>;
1102
+ }
1103
+
1104
+ export interface BaseAppDeps {
1105
+ readonly serviceName: string;
1106
+ readonly version: string;
1107
+ readonly readiness: readonly HealthCheck[];
1108
+ }
1109
+
1110
+ const baseLogger = createLogger('base-app');
1111
+
1112
+ export function createBaseApp(deps: BaseAppDeps): express.Express {
1113
+ const app = express();
1114
+ app.use(express.json({ limit: '1mb' }));
1115
+
1116
+ // Correlation ID — AsyncLocalStorage-scoped for the request's
1117
+ // entire async continuation.
1118
+ app.use((req: any, res: any, next: () => void) => {
1119
+ const id = (req.headers['x-correlation-id'] as string) || randomUUID();
1120
+ req.correlationId = id;
1121
+ res.setHeader('X-Correlation-Id', id);
1122
+ runWithCorrelation(id, next);
1123
+ });
1124
+
1125
+ // Request logging + metrics
1126
+ app.use((req: any, res: any, next: () => void) => {
1127
+ const started = Date.now();
1128
+ res.on('finish', () => {
1129
+ const durationMs = Date.now() - started;
1130
+ baseLogger.info('http.request', {
1131
+ method: req.method,
1132
+ path: req.path,
1133
+ status: res.statusCode,
1134
+ durationMs,
1135
+ });
1136
+ incrementCounter('http_requests_total', {
1137
+ method: req.method,
1138
+ status: String(res.statusCode),
1139
+ });
1140
+ recordHistogram('http_request_duration_ms', durationMs, {
1141
+ method: req.method,
1142
+ });
1143
+ });
1144
+ next();
1145
+ });
1146
+
1147
+ app.get('/health/live', (_req, res) => {
1148
+ res.json({
1149
+ status: 'ok',
1150
+ service: deps.serviceName,
1151
+ version: deps.version,
1152
+ uptime_sec: Math.round(process.uptime()),
1153
+ });
1154
+ });
1155
+
1156
+ app.get('/health/ready', async (_req, res) => {
1157
+ const results: Array<{ name: string; ok: boolean; detail?: string }> = [];
1158
+ for (const check of deps.readiness) {
1159
+ try {
1160
+ const r = await check.check();
1161
+ results.push({ name: check.name, ok: r.ok, ...(r.detail ? { detail: r.detail } : {}) });
1162
+ } catch (err) {
1163
+ results.push({
1164
+ name: check.name,
1165
+ ok: false,
1166
+ detail: err instanceof Error ? err.message : String(err),
1167
+ });
1168
+ }
1169
+ }
1170
+ const allOk = results.every((r) => r.ok);
1171
+ res.status(allOk ? 200 : 503).json({
1172
+ status: allOk ? 'ready' : 'not-ready',
1173
+ checks: results,
1174
+ });
1175
+ });
1176
+
1177
+ app.get('/metrics', metricsHandlerExpress);
1178
+
1179
+ app.use((err: any, req: any, res: any, _next: () => void) => {
1180
+ baseLogger.error('http.error', err as Error, {
1181
+ method: req.method,
1182
+ path: req.path,
1183
+ });
1184
+ const status = err?.httpStatus ?? 500;
1185
+ res.status(status).json({
1186
+ error: {
1187
+ message: err?.message ?? 'Internal error',
1188
+ code: err?.code ?? 'INTERNAL_ERROR',
1189
+ },
1190
+ });
1191
+ });
1192
+
1193
+ return app;
1194
+ }
1195
+ `;
1196
+ export const OWNED_SCAFFOLD_MODULES = [
1197
+ {
1198
+ path: 'src/logger.ts',
1199
+ // ADR-PIPELINE-074: runWithCorrelation is now the authoritative
1200
+ // way to scope a correlation ID; setCorrelationId is retained as a
1201
+ // legacy shim but must not be used inside request handlers.
1202
+ exports: [
1203
+ 'Logger',
1204
+ 'createLogger',
1205
+ 'runWithCorrelation',
1206
+ 'getCorrelationId',
1207
+ 'setCorrelationId',
1208
+ ],
1209
+ },
1210
+ {
1211
+ path: 'src/config.ts',
1212
+ exports: ['AppConfig', 'config'],
1213
+ },
1214
+ {
1215
+ path: 'src/errors.ts',
1216
+ exports: ['AppError', 'ValidationError', 'NotFoundError', 'ERPError'],
1217
+ },
1218
+ {
1219
+ path: 'src/middleware.ts',
1220
+ // ADR-PIPELINE-076: metricsHandler is now a pure renderer; the
1221
+ // Express-compat wrapper is metricsHandlerExpress. The Hono wrapper
1222
+ // lives in the base-app scaffold.
1223
+ exports: [
1224
+ 'correlationId',
1225
+ 'requestLogger',
1226
+ 'incrementCounter',
1227
+ 'recordHistogram',
1228
+ 'metricsHandler',
1229
+ 'metricsHandlerExpress',
1230
+ ],
1231
+ },
1232
+ {
1233
+ path: 'src/circuit-breaker.ts',
1234
+ exports: ['CircuitBreaker'],
1235
+ },
1236
+ {
1237
+ path: 'src/unit-economics.ts',
1238
+ exports: [
1239
+ 'DomainUnit',
1240
+ 'UnitEconomicsScope',
1241
+ 'UnitEconomics',
1242
+ 'writeUnitEconomics',
1243
+ 'readUnitEconomics',
1244
+ ],
1245
+ },
1246
+ {
1247
+ path: 'src/simulation-lineage.ts',
1248
+ exports: [
1249
+ 'SimulationLineage',
1250
+ 'SimulationLineageSource',
1251
+ 'loadSimulationLineage',
1252
+ 'requireSimulationLineage',
1253
+ 'formatLineageBanner',
1254
+ 'loadSimulationId',
1255
+ ],
1256
+ },
1257
+ // ADR-PIPELINE-075: scaffolded persistence layer
1258
+ {
1259
+ path: 'src/persistence/repository.ts',
1260
+ exports: ['Repository', 'RepositoryOptions', 'RepositoryError'],
1261
+ },
1262
+ {
1263
+ path: 'src/persistence/in-memory-repository.ts',
1264
+ exports: ['InMemoryRepository'],
1265
+ },
1266
+ {
1267
+ path: 'src/persistence/sqlite-repository.ts',
1268
+ exports: ['SqliteRepository', 'SqliteRepositoryDeps', 'BetterSqliteDatabase'],
1269
+ },
1270
+ {
1271
+ path: 'src/persistence/audit-repository.ts',
1272
+ exports: ['AppendOnlyAuditRepository', 'AuditRepositoryDeps', 'AuditEntry'],
1273
+ },
1274
+ // ADR-PIPELINE-076: wire-complete server scaffold
1275
+ {
1276
+ path: 'src/api/base-app.ts',
1277
+ exports: [
1278
+ 'createBaseApp',
1279
+ 'BaseAppDeps',
1280
+ 'BaseAppContextVariables',
1281
+ 'HealthCheck',
1282
+ ],
1283
+ },
1284
+ // ADR-PIPELINE-077: ERP schema provenance tag
1285
+ {
1286
+ path: 'src/erp/schema-provenance.ts',
1287
+ exports: [
1288
+ 'ErpProvenanceSource',
1289
+ 'ErpSchemaProvenance',
1290
+ 'assertErpProvenanceOrFail',
1291
+ 'formatProvenanceBanner',
1292
+ 'printProvenanceBanner',
1293
+ ],
1294
+ },
1295
+ // ADR-PIPELINE-078: deep canonical JSON for audit hash chains
1296
+ {
1297
+ path: 'src/persistence/canonical-json.ts',
1298
+ exports: ['CanonicalJsonError', 'canonicalJson'],
1299
+ },
1300
+ {
1301
+ path: 'src/persistence/audit-hash.ts',
1302
+ exports: ['HashChainInput', 'hashAuditEntry'],
1303
+ },
1304
+ ];
1305
+ export function buildOwnedModulesManifest(now = new Date()) {
1306
+ return {
1307
+ version: '1.0',
1308
+ generated_at: now.toISOString(),
1309
+ owned: OWNED_SCAFFOLD_MODULES,
1310
+ };
1311
+ }
1312
+ const SCAFFOLD_SCAN_SKIP_DIRS = new Set([
1313
+ 'node_modules', 'dist', 'build', '.git', 'coverage', '.next', '.agentics',
1314
+ ]);
1315
+ /**
1316
+ * ADR-PIPELINE-069: Walk a project tree looking for generator-emitted files
1317
+ * that redeclare an export listed in OWNED_SCAFFOLD_MODULES. Skips:
1318
+ * - the scaffold-owned files themselves (matched by basename)
1319
+ * - test files
1320
+ * - node_modules / dist / .agentics
1321
+ *
1322
+ * Returns one finding per (file, export) pair.
1323
+ */
1324
+ export function detectScaffoldDuplicates(projectRoot, owned = OWNED_SCAFFOLD_MODULES) {
1325
+ if (!fs.existsSync(projectRoot))
1326
+ return [];
1327
+ // Build (exportName -> ownedPath) lookup once.
1328
+ const exportToOwned = new Map();
1329
+ const ownedBasenames = new Set();
1330
+ for (const mod of owned) {
1331
+ ownedBasenames.add(path.basename(mod.path));
1332
+ for (const ex of mod.exports) {
1333
+ if (!exportToOwned.has(ex))
1334
+ exportToOwned.set(ex, mod.path);
1335
+ }
1336
+ }
1337
+ const findings = [];
1338
+ const walk = (currentDir) => {
1339
+ let entries;
1340
+ try {
1341
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
1342
+ }
1343
+ catch {
1344
+ return;
1345
+ }
1346
+ for (const entry of entries) {
1347
+ if (entry.isDirectory()) {
1348
+ if (SCAFFOLD_SCAN_SKIP_DIRS.has(entry.name))
1349
+ continue;
1350
+ walk(path.join(currentDir, entry.name));
1351
+ continue;
1352
+ }
1353
+ if (!entry.name.endsWith('.ts'))
1354
+ continue;
1355
+ // Skip the scaffolded files themselves — they're allowed to declare
1356
+ // their own exports. Match by basename so any project layout works.
1357
+ if (ownedBasenames.has(entry.name))
1358
+ continue;
1359
+ // Skip tests + scripts + demos
1360
+ const lower = entry.name.toLowerCase();
1361
+ if (lower.endsWith('.test.ts') || lower.endsWith('.spec.ts') || lower.endsWith('.d.ts'))
1362
+ continue;
1363
+ const fullPath = path.join(currentDir, entry.name);
1364
+ const lowerFull = fullPath.toLowerCase();
1365
+ if (lowerFull.includes('/tests/') || lowerFull.includes('/__tests__/') || lowerFull.includes('/scripts/'))
1366
+ continue;
1367
+ let content;
1368
+ try {
1369
+ const stat = fs.statSync(fullPath);
1370
+ if (stat.size > 1_000_000)
1371
+ continue;
1372
+ content = fs.readFileSync(fullPath, 'utf-8');
1373
+ }
1374
+ catch {
1375
+ continue;
1376
+ }
1377
+ for (const [exportName, ownedPath] of exportToOwned) {
1378
+ // Match `export class Foo`, `export function Foo`, `export const Foo`,
1379
+ // `export interface Foo`, `export type Foo`, or `export { Foo }`.
1380
+ const declRe = new RegExp(`\\bexport\\s+(?:class|function|const|let|interface|type|enum)\\s+${exportName}\\b`);
1381
+ const reExportRe = new RegExp(`\\bexport\\s*\\{[^}]*\\b${exportName}\\b[^}]*\\}`);
1382
+ if (declRe.test(content) || reExportRe.test(content)) {
1383
+ findings.push({ path: fullPath, exportName, ownedPath });
1384
+ }
1385
+ }
1386
+ }
1387
+ };
1388
+ walk(projectRoot);
1389
+ return findings;
1390
+ }
90
1391
  function copyPlanningArtifacts(runDir, targetRoot) {
91
1392
  try {
92
1393
  // ADR-051: Use git repo root (or explicit target) instead of process.cwd()
@@ -155,33 +1456,16 @@ function copyPlanningArtifacts(runDir, targetRoot) {
155
1456
  // Instead of hoping the coding agent reads the prompts, we deliver the files.
156
1457
  const scaffoldDir = path.join(plansDir, 'scaffold', 'src');
157
1458
  fs.mkdirSync(scaffoldDir, { recursive: true });
158
- const loggerCode = `// Auto-generated by Agentics pipeline (ADR-039)
159
- export interface Logger {
160
- info(event: string, data?: Record<string, unknown>): void;
161
- warn(event: string, data?: Record<string, unknown>): void;
162
- error(event: string, error: Error, data?: Record<string, unknown>): void;
163
- }
164
-
165
- let currentCorrelationId: string | undefined;
166
-
167
- export function setCorrelationId(id: string): void { currentCorrelationId = id; }
168
- export function getCorrelationId(): string | undefined { return currentCorrelationId; }
169
-
170
- export function createLogger(service: string): Logger {
171
- const emit = (level: string, event: string, extra?: Record<string, unknown>) => {
172
- const entry = { timestamp: new Date().toISOString(), level, service, event, ...(currentCorrelationId ? { correlationId: currentCorrelationId } : {}), ...extra };
173
- process.stderr.write(JSON.stringify(entry) + '\\n');
174
- };
175
- return {
176
- info: (event, data) => emit('info', event, data),
177
- warn: (event, data) => emit('warn', event, data),
178
- error: (event, err, data) => emit('error', event, { error: err.message, stack: err.stack, ...data }),
179
- };
180
- }
181
- `;
182
- const configCode = `// Auto-generated by Agentics pipeline (ADR-039)
1459
+ // ADR-PIPELINE-074: logger scaffold now uses AsyncLocalStorage for
1460
+ // concurrent-request correctness. Body lives in LOGGER_SCAFFOLD at
1461
+ // module scope so it can be unit-tested independently.
1462
+ const loggerCode = LOGGER_SCAFFOLD;
1463
+ // ADR-PIPELINE-039 + ADR-PIPELINE-075: AppConfig now includes a `db`
1464
+ // block so the composition root can pick sqlite (production) vs
1465
+ // in-memory (tests) without code changes.
1466
+ const configCode = `// Auto-generated by Agentics pipeline (ADR-039 + ADR-PIPELINE-075)
183
1467
  export interface AppConfig {
184
- env: 'development' | 'staging' | 'production';
1468
+ env: 'development' | 'staging' | 'production' | 'test';
185
1469
  port: number;
186
1470
  logLevel: 'debug' | 'info' | 'warn' | 'error';
187
1471
  erp: {
@@ -190,6 +1474,10 @@ export interface AppConfig {
190
1474
  timeoutMs: number;
191
1475
  maxRetries: number;
192
1476
  };
1477
+ db: {
1478
+ driver: 'sqlite' | 'in-memory';
1479
+ path: string;
1480
+ };
193
1481
  }
194
1482
 
195
1483
  export const config: AppConfig = {
@@ -202,6 +1490,10 @@ export const config: AppConfig = {
202
1490
  timeoutMs: parseInt(process.env['ERP_TIMEOUT_MS'] ?? '30000', 10),
203
1491
  maxRetries: parseInt(process.env['ERP_MAX_RETRIES'] ?? '3', 10),
204
1492
  },
1493
+ db: {
1494
+ driver: (process.env['DB_DRIVER'] ?? 'sqlite') as AppConfig['db']['driver'],
1495
+ path: process.env['DB_PATH'] ?? './data/app.db',
1496
+ },
205
1497
  };
206
1498
  `;
207
1499
  const errorsCode = `// Auto-generated by Agentics pipeline (ADR-039)
@@ -232,21 +1524,49 @@ export class ERPError extends AppError {
232
1524
  `;
233
1525
  // TypeScript scaffold (default)
234
1526
  fs.writeFileSync(path.join(scaffoldDir, 'logger.ts'), loggerCode, 'utf-8');
1527
+ // ADR-PIPELINE-074: ship a concurrency regression test next to the
1528
+ // logger so generated projects fail loudly if someone reintroduces
1529
+ // a module-level correlation-ID store.
1530
+ fs.writeFileSync(path.join(scaffoldDir, 'logger.concurrency.test.ts'), LOGGER_CONCURRENCY_TEST_SCAFFOLD, 'utf-8');
235
1531
  fs.writeFileSync(path.join(scaffoldDir, 'config.ts'), configCode, 'utf-8');
236
1532
  fs.writeFileSync(path.join(scaffoldDir, 'errors.ts'), errorsCode, 'utf-8');
237
- // ADR-051: Correlation ID middleware + metrics scaffold
238
- const middlewareCode = `// Auto-generated by Agentics pipeline (ADR-051)
1533
+ // ADR-PIPELINE-075: Scaffolded persistence layer Repository<T> +
1534
+ // InMemoryRepository + SqliteRepository + AppendOnlyAuditRepository.
1535
+ // Every stateful service should import these instead of rolling a
1536
+ // Map<string, T> store. PGV-017 flags the anti-pattern at post-gen time.
1537
+ const persistenceDir = path.join(scaffoldDir, 'persistence');
1538
+ fs.mkdirSync(persistenceDir, { recursive: true });
1539
+ fs.writeFileSync(path.join(persistenceDir, 'repository.ts'), REPOSITORY_SCAFFOLD, 'utf-8');
1540
+ fs.writeFileSync(path.join(persistenceDir, 'in-memory-repository.ts'), IN_MEMORY_REPO_SCAFFOLD, 'utf-8');
1541
+ fs.writeFileSync(path.join(persistenceDir, 'sqlite-repository.ts'), SQLITE_REPO_SCAFFOLD, 'utf-8');
1542
+ fs.writeFileSync(path.join(persistenceDir, 'audit-repository.ts'), AUDIT_REPO_SCAFFOLD, 'utf-8');
1543
+ // ADR-PIPELINE-078: deep canonical JSON + audit hash helper.
1544
+ // Every generated AuditService should import hashAuditEntry from this
1545
+ // helper instead of hand-rolling JSON.stringify + createHash chains.
1546
+ // PGV-022 flags the anti-pattern at post-gen time.
1547
+ fs.writeFileSync(path.join(persistenceDir, 'canonical-json.ts'), CANONICAL_JSON_SCAFFOLD, 'utf-8');
1548
+ fs.writeFileSync(path.join(persistenceDir, 'audit-hash.ts'), AUDIT_HASH_SCAFFOLD, 'utf-8');
1549
+ // ADR-051 + ADR-PIPELINE-074: Correlation ID middleware now wraps next()
1550
+ // in runWithCorrelation so the ID lives in AsyncLocalStorage for the
1551
+ // request's entire async continuation. Concurrent requests cannot
1552
+ // cross-contaminate log lines.
1553
+ const middlewareCode = `// Auto-generated by Agentics pipeline (ADR-051 + ADR-PIPELINE-074)
239
1554
  import { randomUUID } from 'node:crypto';
240
- import { createLogger } from './logger.js';
1555
+ import { createLogger, runWithCorrelation } from './logger.js';
241
1556
 
242
1557
  const logger = createLogger('middleware');
243
1558
 
244
- /** Correlation ID middleware — attaches to every request, threads through logs */
1559
+ /**
1560
+ * Correlation ID middleware — attaches to every request, threads through
1561
+ * logs via AsyncLocalStorage. The request handler + all its async
1562
+ * descendants run inside runWithCorrelation so getCorrelationId() returns
1563
+ * this request's ID, not a sibling's.
1564
+ */
245
1565
  export function correlationId(req: any, res: any, next: () => void): void {
246
1566
  const id = (req.headers['x-correlation-id'] as string) || randomUUID();
247
1567
  req.correlationId = id;
248
1568
  res.setHeader('X-Correlation-Id', id);
249
- next();
1569
+ runWithCorrelation(id, next);
250
1570
  }
251
1571
 
252
1572
  /** Request logging middleware — logs every request with timing */
@@ -276,7 +1596,13 @@ export function recordHistogram(name: string, value: number, labels: Record<stri
276
1596
  histograms[key]!.push(value);
277
1597
  }
278
1598
 
279
- export function metricsHandler(_req: any, res: any): void {
1599
+ /**
1600
+ * ADR-PIPELINE-076: Pure metrics renderer — returns Prometheus text.
1601
+ * Framework-specific wrappers (metricsHandlerExpress, metricsHandlerHono)
1602
+ * delegate here. Call this directly from any framework that isn't
1603
+ * Express or Hono.
1604
+ */
1605
+ export function metricsHandler(): string {
280
1606
  const lines: string[] = [];
281
1607
  for (const [key, val] of Object.entries(counters)) {
282
1608
  lines.push(\`# TYPE \${key.split('{')[0]} counter\`);
@@ -289,55 +1615,188 @@ export function metricsHandler(_req: any, res: any): void {
289
1615
  lines.push(\`\${key}_sum \${sum}\`);
290
1616
  lines.push(\`\${key}_count \${count}\`);
291
1617
  }
292
- res.setHeader('Content-Type', 'text/plain');
293
- res.end(lines.join('\\n'));
1618
+ return lines.join('\\n');
294
1619
  }
295
1620
 
296
- /** Simple circuit breaker for external service calls */
297
- export class CircuitBreaker {
298
- private failures = 0;
299
- private state: 'closed' | 'open' | 'half-open' = 'closed';
300
- private nextAttempt = 0;
1621
+ /** Express-compatible metrics handler wraps metricsHandler(). */
1622
+ export function metricsHandlerExpress(_req: any, res: any): void {
1623
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4');
1624
+ res.end(metricsHandler());
1625
+ }
301
1626
 
302
- constructor(
303
- private readonly name: string,
304
- private readonly threshold = 5,
305
- private readonly cooldownMs = 30000,
306
- private readonly logger = createLogger('circuit-breaker'),
307
- ) {}
1627
+ // ADR-PIPELINE-069: CircuitBreaker now lives in its own scaffold module.
1628
+ // This re-export keeps existing imports (from './middleware.js') working
1629
+ // while making the canonical class importable from a dedicated file.
1630
+ export { CircuitBreaker } from './circuit-breaker.js';
1631
+ `;
1632
+ fs.writeFileSync(path.join(scaffoldDir, 'middleware.ts'), middlewareCode, 'utf-8');
1633
+ totalCopied += 1;
1634
+ // ADR-PIPELINE-069: standalone scaffolded circuit-breaker module so
1635
+ // generators can import CircuitBreaker without pulling all of
1636
+ // middleware.ts. Owned by the scaffold (see OWNED_SCAFFOLD_MODULES).
1637
+ fs.writeFileSync(path.join(scaffoldDir, 'circuit-breaker.ts'), CIRCUIT_BREAKER_SCAFFOLD, 'utf-8');
1638
+ totalCopied += 1;
1639
+ // ADR-PIPELINE-076: wire-complete Hono base app. Generators extend
1640
+ // via createBaseApp(deps).route('/api/<domain>', router) instead of
1641
+ // rebuilding the middleware/metrics/health plumbing from scratch.
1642
+ // PGV-018 will fail the build if /metrics, /health/live, /health/ready
1643
+ // are missing from the final project tree.
1644
+ const apiDir = path.join(scaffoldDir, 'api');
1645
+ fs.mkdirSync(apiDir, { recursive: true });
1646
+ fs.writeFileSync(path.join(apiDir, 'base-app.ts'), BASE_APP_SCAFFOLD_HONO, 'utf-8');
1647
+ totalCopied += 1;
1648
+ // ADR-PIPELINE-077: ERP schema provenance helper. Every generated
1649
+ // ERP adapter MUST export an ERP_SCHEMA_PROVENANCE constant and call
1650
+ // assertErpProvenanceOrFail at construction so strict-mode deployments
1651
+ // block on unreviewed schemas. PGV-020 enforces presence, PGV-021
1652
+ // enforces reviewer + catalog_version on validated entries.
1653
+ const erpDir = path.join(scaffoldDir, 'erp');
1654
+ fs.mkdirSync(erpDir, { recursive: true });
1655
+ fs.writeFileSync(path.join(erpDir, 'schema-provenance.ts'), ERP_SCHEMA_PROVENANCE_SCAFFOLD, 'utf-8');
1656
+ totalCopied += 1;
1657
+ // ADR-PIPELINE-066: unit-economics helper. The demo script calls
1658
+ // writeUnitEconomics() to emit a machine-readable manifest that the
1659
+ // executive renderer prefers over per-employee heuristics.
1660
+ const unitEconomicsCode = `// Auto-generated by Agentics pipeline (ADR-PIPELINE-066)
1661
+ // Writes .agentics/runs/<run-id>/unit-economics.json so the executive
1662
+ // renderer can use bottom-up unit economics instead of heuristics.
1663
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
1664
+ import { dirname, join, resolve } from 'node:path';
1665
+ import { fileURLToPath } from 'node:url';
1666
+
1667
+ export interface DomainUnit {
1668
+ label: string; // e.g. "occupied room night"
1669
+ abbrev: string; // e.g. "orn"
1670
+ }
308
1671
 
309
- async call<T>(fn: () => Promise<T>): Promise<T> {
310
- if (this.state === 'open') {
311
- if (Date.now() < this.nextAttempt) {
312
- throw new Error(\`Circuit breaker '\${this.name}' is OPEN — retry after \${new Date(this.nextAttempt).toISOString()}\`);
313
- }
314
- this.state = 'half-open';
315
- this.logger.info('circuit.half-open', { name: this.name });
316
- }
317
- try {
318
- const result = await fn();
319
- if (this.state === 'half-open') {
320
- this.state = 'closed';
321
- this.failures = 0;
322
- this.logger.info('circuit.closed', { name: this.name });
323
- }
324
- return result;
325
- } catch (err) {
326
- this.failures++;
327
- if (this.failures >= this.threshold) {
328
- this.state = 'open';
329
- this.nextAttempt = Date.now() + this.cooldownMs;
330
- this.logger.warn('circuit.open', { name: this.name, failures: this.failures, retryAt: new Date(this.nextAttempt).toISOString() });
331
- }
332
- throw err;
333
- }
1672
+ export interface UnitEconomicsScope {
1673
+ properties?: number;
1674
+ rooms?: number;
1675
+ sqft?: number;
1676
+ vehicles?: number;
1677
+ beds?: number;
1678
+ agents?: number;
1679
+ units?: number;
1680
+ employees?: number;
1681
+ region_mix?: Array<'NA' | 'EMEA' | 'APAC' | 'LATAM' | 'MEA'>;
1682
+ }
1683
+
1684
+ export interface UnitEconomics {
1685
+ run_id: string;
1686
+ sector: string;
1687
+ domain_unit: DomainUnit;
1688
+ measured_scope: UnitEconomicsScope;
1689
+ enterprise_scope: UnitEconomicsScope;
1690
+ unit_savings: Record<string, number>;
1691
+ annual_measured_savings_usd: number;
1692
+ annual_extrapolated_savings_usd: number;
1693
+ extrapolation_method: string;
1694
+ confidence: { directional: number; precision: number };
1695
+ generated_at?: string;
1696
+ source?: 'prototype';
1697
+ }
1698
+
1699
+ function findRunDir(): string | null {
1700
+ // 1. Explicit override
1701
+ const override = process.env['AGENTICS_RUN_DIR'];
1702
+ if (override && existsSync(override)) return override;
1703
+
1704
+ // 2. Walk upward looking for .agentics/runs/latest
1705
+ let dir: string;
1706
+ try {
1707
+ dir = dirname(fileURLToPath(import.meta.url));
1708
+ } catch {
1709
+ dir = process.cwd();
1710
+ }
1711
+ for (let i = 0; i < 6; i++) {
1712
+ const candidate = join(dir, '.agentics', 'runs', 'latest');
1713
+ if (existsSync(candidate)) return resolve(candidate);
1714
+ const parent = dirname(dir);
1715
+ if (parent === dir) break;
1716
+ dir = parent;
334
1717
  }
335
1718
 
336
- getState(): string { return this.state; }
1719
+ // 3. Build from run id if we can find one
1720
+ const simId = process.env['AGENTICS_SIMULATION_ID'];
1721
+ if (simId) {
1722
+ const dest = join(process.cwd(), '.agentics', 'runs', simId);
1723
+ return dest;
1724
+ }
1725
+
1726
+ return join(process.cwd(), '.agentics', 'runs', 'local-run');
1727
+ }
1728
+
1729
+ export function writeUnitEconomics(manifest: UnitEconomics): string {
1730
+ const runDir = findRunDir();
1731
+ if (!runDir) throw new Error('ECLI-UE-066: unable to resolve run directory');
1732
+ mkdirSync(runDir, { recursive: true });
1733
+ const out = {
1734
+ source: 'prototype' as const,
1735
+ generated_at: new Date().toISOString(),
1736
+ ...manifest,
1737
+ };
1738
+ const filePath = join(runDir, 'unit-economics.json');
1739
+ writeFileSync(filePath, JSON.stringify(out, null, 2) + '\\n', 'utf-8');
1740
+ return filePath;
1741
+ }
1742
+
1743
+ export function readUnitEconomics(runDir: string): UnitEconomics | null {
1744
+ const filePath = join(runDir, 'unit-economics.json');
1745
+ if (!existsSync(filePath)) return null;
1746
+ try {
1747
+ return JSON.parse(readFileSync(filePath, 'utf-8')) as UnitEconomics;
1748
+ } catch {
1749
+ return null;
1750
+ }
337
1751
  }
338
1752
  `;
339
- fs.writeFileSync(path.join(scaffoldDir, 'middleware.ts'), middlewareCode, 'utf-8');
1753
+ fs.writeFileSync(path.join(scaffoldDir, 'unit-economics.ts'), unitEconomicsCode, 'utf-8');
1754
+ totalCopied += 1;
1755
+ // ADR-PIPELINE-068: ESM-safe simulation-lineage helper. Every generated
1756
+ // project gets a loader that reads .agentics/plans/manifest.json using
1757
+ // readFileSync + fileURLToPath. NEVER use CommonJS require() in the
1758
+ // generated project — it is "type": "module" and require() throws at
1759
+ // runtime, producing a silent sim-unknown fallback that severs lineage.
1760
+ // The string body lives at module scope (SIMULATION_LINEAGE_SCAFFOLD)
1761
+ // so it can be unit-tested without invoking copyPlanningArtifacts.
1762
+ fs.writeFileSync(path.join(scaffoldDir, 'simulation-lineage.ts'), SIMULATION_LINEAGE_SCAFFOLD, 'utf-8');
340
1763
  totalCopied += 1;
1764
+ // ADR-PIPELINE-069: Sidecar manifest listing every scaffold-owned
1765
+ // module + its public exports. Consumed by:
1766
+ // - prompt-generator.ts (injects "do not reimplement" block)
1767
+ // - post-generation-validator PGV-012 (bans duplicate declarations)
1768
+ // The manifest is the single source of truth — generators MUST NOT
1769
+ // re-emit any export listed here.
1770
+ const ownedManifestPath = path.join(plansDir, 'scaffold', 'OWNED_MODULES.json');
1771
+ fs.writeFileSync(ownedManifestPath, JSON.stringify(buildOwnedModulesManifest(), null, 2) + '\n', 'utf-8');
1772
+ totalCopied += 1;
1773
+ // ADR-PIPELINE-069: Cleanup pass — scan the project tree for files
1774
+ // that redeclare an export listed in OWNED_MODULES.json. Logs the
1775
+ // count; under AGENTICS_AUTO_DEDUPE=true, deletes the duplicates.
1776
+ try {
1777
+ const projectRootForScan = projectRoot;
1778
+ const dupes = detectScaffoldDuplicates(projectRootForScan, OWNED_SCAFFOLD_MODULES);
1779
+ const autoDedupe = process.env['AGENTICS_AUTO_DEDUPE'] === 'true';
1780
+ if (dupes.length > 0) {
1781
+ console.error(` [SCAFFOLD] scaffold.duplicate.detected count=${dupes.length}` +
1782
+ (autoDedupe ? ' (auto-deduping)' : ' (set AGENTICS_AUTO_DEDUPE=true to delete)'));
1783
+ for (const d of dupes) {
1784
+ console.error(` - ${d.path} redeclares ${d.exportName} (owned by ${d.ownedPath})`);
1785
+ if (autoDedupe) {
1786
+ try {
1787
+ fs.unlinkSync(d.path);
1788
+ }
1789
+ catch { /* best-effort */ }
1790
+ }
1791
+ }
1792
+ }
1793
+ else {
1794
+ console.error(' [SCAFFOLD] scaffold.duplicate.detected: 0');
1795
+ }
1796
+ }
1797
+ catch {
1798
+ // Cleanup is best-effort — never block scaffold emission on a scan failure.
1799
+ }
341
1800
  // Python scaffold (if SPARC specifies Python or query mentions it)
342
1801
  const pyDir = path.join(plansDir, 'scaffold', 'python');
343
1802
  fs.mkdirSync(pyDir, { recursive: true });
@@ -1562,6 +3021,46 @@ These numbers MUST come from the actual scoring/analysis results on seed data,
1562
3021
  not from generic heuristics. The evaluator checks that the business case
1563
3022
  references specific prototype findings.
1564
3023
 
3024
+ ## 1c. Unit Economics Manifest (ADR-PIPELINE-066) — REQUIRED
3025
+
3026
+ Copy \`src/unit-economics.ts\` from scaffold and call \`writeUnitEconomics(...)\`
3027
+ at the end of the demo run. This emits \`.agentics/runs/<run-id>/unit-economics.json\`,
3028
+ the machine-readable contract the executive renderer prefers over per-employee
3029
+ heuristics.
3030
+
3031
+ Required fields (derive every number from the prototype's actual computations,
3032
+ NOT from employee headcount):
3033
+
3034
+ \`\`\`typescript
3035
+ import { writeUnitEconomics } from './unit-economics.js';
3036
+
3037
+ writeUnitEconomics({
3038
+ run_id: process.env.AGENTICS_SIMULATION_ID ?? 'local-run',
3039
+ sector: 'hospitality', // or the detected sector
3040
+ domain_unit: { label: 'occupied room night', abbrev: 'orn' },
3041
+ measured_scope: { properties: 4, rooms: 1450, region_mix: ['NA','EMEA','APAC'] },
3042
+ enterprise_scope: { properties: 100, rooms: 36250, employees: 91000 },
3043
+ unit_savings: {
3044
+ usd_per_orn: 0.82, // or usd_per_sqft_year / usd_per_vehicle_mile / ...
3045
+ water_liters_per_orn: 14.5,
3046
+ co2_kg_per_orn: 0.62,
3047
+ },
3048
+ annual_measured_savings_usd: measuredSavings, // from the pilot scenario
3049
+ annual_extrapolated_savings_usd: enterpriseSavings,
3050
+ extrapolation_method: 'linear_by_rooms_weighted_by_regional_cost',
3051
+ confidence: { directional: 0.92, precision: 0.42 },
3052
+ });
3053
+ \`\`\`
3054
+
3055
+ IMPORTANT:
3056
+ - **Do NOT multiply employees × $/employee anywhere** to derive the savings figures.
3057
+ - \`measured_scope\` must reflect the real pilot data the prototype analyzed.
3058
+ - \`enterprise_scope\` and \`annual_extrapolated_savings_usd\` must use a clearly
3059
+ named extrapolation method (linear by rooms, weighted by region, etc.).
3060
+ - The executive renderer will refuse to emit a financial analysis if the printed
3061
+ figures drift more than ±1% from the manifest or the scope ratio deviates by
3062
+ more than ±5% (ECLI-SYN-066).
3063
+
1565
3064
  Also print an "Analysis Confidence" section:
1566
3065
  \`\`\`
1567
3066
  ═══ ANALYSIS CONFIDENCE ═══