@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.
- package/dist/pipeline/auto-chain.d.ts +190 -0
- package/dist/pipeline/auto-chain.d.ts.map +1 -1
- package/dist/pipeline/auto-chain.js +1571 -72
- package/dist/pipeline/auto-chain.js.map +1 -1
- package/dist/pipeline/phase2/phases/prompt-generator.d.ts.map +1 -1
- package/dist/pipeline/phase2/phases/prompt-generator.js +205 -12
- package/dist/pipeline/phase2/phases/prompt-generator.js.map +1 -1
- package/dist/pipeline/phase2/schemas.d.ts +10 -10
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts +12 -0
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.js +92 -25
- package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
- package/dist/pipeline/phase4-5-pre-render/financial-model.d.ts +51 -0
- package/dist/pipeline/phase4-5-pre-render/financial-model.d.ts.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/financial-model.js +118 -0
- package/dist/pipeline/phase4-5-pre-render/financial-model.js.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.d.ts +53 -0
- package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.d.ts.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.js +130 -0
- package/dist/pipeline/phase4-5-pre-render/post-render-reconciler.js.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.d.ts +47 -0
- package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.d.ts.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.js +105 -0
- package/dist/pipeline/phase4-5-pre-render/pre-render-coordinator.js.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/sector-baselines.d.ts +42 -0
- package/dist/pipeline/phase4-5-pre-render/sector-baselines.d.ts.map +1 -0
- package/dist/pipeline/phase4-5-pre-render/sector-baselines.js +117 -0
- package/dist/pipeline/phase4-5-pre-render/sector-baselines.js.map +1 -0
- package/dist/pipeline/phase5-build/phase5-build-coordinator.d.ts.map +1 -1
- package/dist/pipeline/phase5-build/phase5-build-coordinator.js +44 -0
- package/dist/pipeline/phase5-build/phase5-build-coordinator.js.map +1 -1
- package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts +75 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.d.ts.map +1 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.js +1068 -0
- package/dist/pipeline/phase5-build/phases/post-generation-validator.js.map +1 -0
- package/dist/pipeline/phase5-build/types.d.ts +1 -1
- package/dist/pipeline/phase5-build/types.d.ts.map +1 -1
- package/dist/pipeline/types.d.ts +87 -0
- package/dist/pipeline/types.d.ts.map +1 -1
- package/dist/pipeline/types.js +51 -1
- package/dist/pipeline/types.js.map +1 -1
- package/dist/synthesis/consensus-svg.d.ts +19 -0
- package/dist/synthesis/consensus-svg.d.ts.map +1 -0
- package/dist/synthesis/consensus-svg.js +95 -0
- package/dist/synthesis/consensus-svg.js.map +1 -0
- package/dist/synthesis/consensus-tiers.d.ts +99 -0
- package/dist/synthesis/consensus-tiers.d.ts.map +1 -0
- package/dist/synthesis/consensus-tiers.js +285 -0
- package/dist/synthesis/consensus-tiers.js.map +1 -0
- package/dist/synthesis/domain-labor-classifier.d.ts +101 -0
- package/dist/synthesis/domain-labor-classifier.d.ts.map +1 -0
- package/dist/synthesis/domain-labor-classifier.js +312 -0
- package/dist/synthesis/domain-labor-classifier.js.map +1 -0
- package/dist/synthesis/domain-unit-registry.d.ts +59 -0
- package/dist/synthesis/domain-unit-registry.d.ts.map +1 -0
- package/dist/synthesis/domain-unit-registry.js +320 -0
- package/dist/synthesis/domain-unit-registry.js.map +1 -0
- package/dist/synthesis/financial-claim-extractor.d.ts +72 -0
- package/dist/synthesis/financial-claim-extractor.d.ts.map +1 -0
- package/dist/synthesis/financial-claim-extractor.js +382 -0
- package/dist/synthesis/financial-claim-extractor.js.map +1 -0
- package/dist/synthesis/financial-consistency-rules.d.ts +70 -0
- package/dist/synthesis/financial-consistency-rules.d.ts.map +1 -0
- package/dist/synthesis/financial-consistency-rules.js +483 -0
- package/dist/synthesis/financial-consistency-rules.js.map +1 -0
- package/dist/synthesis/financial-consistency-runner.d.ts +73 -0
- package/dist/synthesis/financial-consistency-runner.d.ts.map +1 -0
- package/dist/synthesis/financial-consistency-runner.js +131 -0
- package/dist/synthesis/financial-consistency-runner.js.map +1 -0
- package/dist/synthesis/forbidden-spin-phrases.d.ts +32 -0
- package/dist/synthesis/forbidden-spin-phrases.d.ts.map +1 -0
- package/dist/synthesis/forbidden-spin-phrases.js +84 -0
- package/dist/synthesis/forbidden-spin-phrases.js.map +1 -0
- package/dist/synthesis/phase-gate-thresholds.d.ts +30 -0
- package/dist/synthesis/phase-gate-thresholds.d.ts.map +1 -0
- package/dist/synthesis/phase-gate-thresholds.js +34 -0
- package/dist/synthesis/phase-gate-thresholds.js.map +1 -0
- package/dist/synthesis/prompts/index.d.ts.map +1 -1
- package/dist/synthesis/prompts/index.js +22 -0
- package/dist/synthesis/prompts/index.js.map +1 -1
- package/dist/synthesis/roadmap-dates.d.ts +72 -0
- package/dist/synthesis/roadmap-dates.d.ts.map +1 -0
- package/dist/synthesis/roadmap-dates.js +203 -0
- package/dist/synthesis/roadmap-dates.js.map +1 -0
- package/dist/synthesis/simulation-artifact-generator.d.ts.map +1 -1
- package/dist/synthesis/simulation-artifact-generator.js +135 -1
- package/dist/synthesis/simulation-artifact-generator.js.map +1 -1
- package/dist/synthesis/simulation-renderers.d.ts +105 -2
- package/dist/synthesis/simulation-renderers.d.ts.map +1 -1
- package/dist/synthesis/simulation-renderers.js +1192 -123
- package/dist/synthesis/simulation-renderers.js.map +1 -1
- package/dist/synthesis/unit-economics-loader.d.ts +71 -0
- package/dist/synthesis/unit-economics-loader.d.ts.map +1 -0
- package/dist/synthesis/unit-economics-loader.js +200 -0
- package/dist/synthesis/unit-economics-loader.js.map +1 -0
- 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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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-
|
|
238
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
res.end(lines.join('\\n'));
|
|
1618
|
+
return lines.join('\\n');
|
|
294
1619
|
}
|
|
295
1620
|
|
|
296
|
-
/**
|
|
297
|
-
export
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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, '
|
|
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 ═══
|