kiro-spec-engine 1.47.12 → 1.47.15

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/CHANGELOG.md CHANGED
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
  ### Added
11
+ - **Moqui runtime binding config overrides**: Added `--moqui-config <path>` to `kse scene run` and `kse scene doctor`, allowing runtime binding resolution to use an explicit `moqui-adapter.json` path per execution context.
12
+ - **Moqui client rate-limit resilience tests**: Added dedicated unit coverage for `429 Too Many Requests` retry/exhaustion handling and retryable network error recovery in `tests/unit/scene-runtime/moqui-client.test.js`.
13
+ - **Template ontology contract completeness**: Hardened scene package template contract examples to include ontology entities/relations plus governance lineage/rules/decision sections required by strict lint and ontology validation flows.
11
14
  - **Scene ontology impact/path analysis commands**: Added `kse scene ontology impact` (reverse dependency blast-radius analysis with `--relation` and `--max-depth`) and `kse scene ontology path` (shortest relation path between refs with optional `--undirected`) to improve ontology-driven change planning and explainability.
12
15
  - **Close-loop quantitative DoD gates**: Added `--dod-max-risk-level`, `--dod-kpi-min-completion-rate`, `--dod-max-success-rate-drop`, and `--dod-baseline-window` so autonomous closure can enforce explicit risk/KPI/baseline thresholds beyond binary checks.
13
16
  - **Close-loop conflict/ontology execution planning**: Added lease-conflict scheduling governance and scene ontology scheduling guidance with opt-out controls (`--no-conflict-governance`, `--no-ontology-guidance`), and surfaced planning telemetry in `portfolio.execution_plan` plus agent sync plan output.
@@ -114,6 +117,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
114
117
  - **Live orchestration status streaming**: Added event/interval-driven status persistence callback support in `runOrchestration()` and wired `auto close-loop` live progress output (`--no-stream` to disable).
115
118
 
116
119
  ### Changed
120
+ - **Default ERP binding routing now prefers Moqui when configured**: Runtime default handler order now resolves `spec.erp.*` through `moqui.adapter` first when adapter config is present, while preserving deterministic fallback to `builtin.erp-sim` when config is unavailable.
121
+ - **Moqui extraction output enriched for AI-native ontology usage**: Extracted manifests/contracts now emit action intent semantics, dependency chains, governance lineage, ontology model entities/relations, and agent hints for downstream planning.
117
122
  - **Controller queue hygiene default**: `close-loop-controller` now deduplicates duplicate broad goals by default (`--no-controller-dedupe` to preserve raw duplicates), and summary telemetry includes dedupe/lock/session metadata.
118
123
  - **Positioning and onboarding messaging**: Strengthened EN/ZH README and quick-start docs with explicit kse advantage matrix, 90-second value proof, and KPI observability positioning to improve first-contact clarity.
119
124
  - **CLI first-screen positioning text**: Updated `kse --help` top description in EN/ZH locales to reflect current core strengths: Spec workflow, orchestration, and KPI observability.
@@ -127,6 +132,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
127
132
  - **Autonomous operator UX**: Expanded docs with semantic decomposition and live stream behavior for close-loop command usage.
128
133
 
129
134
  ### Fixed
135
+ - **Scene runtime 429 failure behavior under high request pressure**: Moqui HTTP client now retries on `429` with `Retry-After` support and bounded exponential backoff, reducing multi-agent stalls caused by transient service-side request limits.
136
+ - **Moqui adapter matching fallback safety**: `spec.erp.*` bindings no longer get captured by Moqui handler when no adapter config exists, preventing false hard-fail paths and restoring expected simulator fallback behavior.
130
137
  - **Controller summary semantics**: `close-loop-controller` now reports final `pending_goals` from the persisted queue snapshot and only marks `cycle-limit-reached` as exhausted when pending work remains (or empty-polling mode explicitly consumes cycle budget).
131
138
  - **npm package hygiene**: Excluded transient Python bytecode artifacts (`__pycache__`, `*.pyc/pyo/pyd`) from published package contents to reduce package noise and size.
132
139
  - **Documentation contact placeholders**: Replaced `yourusername` placeholder repository links in onboarding docs with the canonical project URL and removed stale example email contact.
package/README.md CHANGED
@@ -413,6 +413,12 @@ Tip: `kse spec bootstrap|pipeline run|gate run --specs ...` now defaults to this
413
413
  "maxParallel": 3,
414
414
  "timeoutSeconds": 900,
415
415
  "maxRetries": 2,
416
+ "rateLimitMaxRetries": 6,
417
+ "rateLimitBackoffBaseMs": 1000,
418
+ "rateLimitBackoffMaxMs": 30000,
419
+ "rateLimitAdaptiveParallel": true,
420
+ "rateLimitParallelFloor": 1,
421
+ "rateLimitCooldownMs": 30000,
416
422
  "apiKeyEnvVar": "CODEX_API_KEY",
417
423
  "codexArgs": ["--skip-git-repo-check"],
418
424
  "codexCommand": "npx @openai/codex"
@@ -420,6 +426,7 @@ Tip: `kse spec bootstrap|pipeline run|gate run --specs ...` now defaults to this
420
426
  ```
421
427
 
422
428
  If Codex CLI is globally installed, you can set `"codexCommand": "codex"`.
429
+ Use the `rateLimit*` settings to absorb transient 429/too-many-requests failures without stalling orchestration.
423
430
 
424
431
  ### Spec-Level Steering & Context Sync 🚀 NEW in v1.44.0
425
432
  - **Spec Steering (L4)**: Independent `steering.md` per Spec with constraints, notes, and decisions — zero cross-agent conflict
package/README.zh.md CHANGED
@@ -367,6 +367,12 @@ kse orchestrate stop
367
367
  "maxParallel": 3,
368
368
  "timeoutSeconds": 900,
369
369
  "maxRetries": 2,
370
+ "rateLimitMaxRetries": 6,
371
+ "rateLimitBackoffBaseMs": 1000,
372
+ "rateLimitBackoffMaxMs": 30000,
373
+ "rateLimitAdaptiveParallel": true,
374
+ "rateLimitParallelFloor": 1,
375
+ "rateLimitCooldownMs": 30000,
370
376
  "apiKeyEnvVar": "CODEX_API_KEY",
371
377
  "codexArgs": ["--skip-git-repo-check"],
372
378
  "codexCommand": "npx @openai/codex"
@@ -374,6 +380,7 @@ kse orchestrate stop
374
380
  ```
375
381
 
376
382
  如果你已全局安装 Codex CLI,可将 `"codexCommand"` 改为 `"codex"`。
383
+ 可通过 `rateLimit*` 配置吸收 429/too-many-requests 等限流抖动,避免编排流程卡死。
377
384
 
378
385
  ### Spec 级 Steering 与上下文同步 🚀 v1.44.0 新增
379
386
  - **Spec Steering (L4)**: 每个 Spec 独立的 `steering.md`,包含约束、注意事项、决策记录 — 跨 Agent 零冲突
@@ -787,12 +787,20 @@ Recommended `.kiro/config/orchestrator.json`:
787
787
  "maxParallel": 3,
788
788
  "timeoutSeconds": 900,
789
789
  "maxRetries": 2,
790
+ "rateLimitMaxRetries": 6,
791
+ "rateLimitBackoffBaseMs": 1000,
792
+ "rateLimitBackoffMaxMs": 30000,
793
+ "rateLimitAdaptiveParallel": true,
794
+ "rateLimitParallelFloor": 1,
795
+ "rateLimitCooldownMs": 30000,
790
796
  "apiKeyEnvVar": "CODEX_API_KEY",
791
797
  "codexArgs": ["--skip-git-repo-check"],
792
798
  "codexCommand": "npx @openai/codex"
793
799
  }
794
800
  ```
795
801
 
802
+ `rateLimit*` settings provide dedicated retry/backoff and adaptive parallel throttling when providers return 429 / too-many-requests errors.
803
+
796
804
  ### Scene Template Engine
797
805
 
798
806
  ```bash
@@ -124,6 +124,9 @@ async function runOrchestration(options = {}, dependencies = {}) {
124
124
  'spec:start',
125
125
  'spec:complete',
126
126
  'spec:failed',
127
+ 'spec:rate-limited',
128
+ 'parallel:throttled',
129
+ 'parallel:recovered',
127
130
  'orchestration:complete'
128
131
  ];
129
132
 
@@ -322,6 +325,17 @@ function _printStatus(status) {
322
325
  if (status.currentBatch !== undefined && status.totalBatches !== undefined) {
323
326
  console.log(`Batch: ${status.currentBatch} / ${status.totalBatches}`);
324
327
  }
328
+ if (status.parallel) {
329
+ const effective = status.parallel.effectiveMaxParallel ?? '-';
330
+ const max = status.parallel.maxParallel ?? '-';
331
+ const adaptive = status.parallel.adaptive === false ? 'off' : 'on';
332
+ console.log(`Parallel: ${effective} / ${max} (adaptive: ${adaptive})`);
333
+ }
334
+ if (status.rateLimit) {
335
+ const signals = status.rateLimit.signalCount || 0;
336
+ const backoff = status.rateLimit.totalBackoffMs || 0;
337
+ console.log(`Rate-limit: ${signals} signal(s), total backoff ${backoff}ms`);
338
+ }
325
339
  if (status.specs) {
326
340
  console.log('');
327
341
  for (const [name, info] of Object.entries(status.specs)) {
@@ -183,6 +183,7 @@ function registerSceneCommands(program) {
183
183
  .option('--safety-preflight', 'Set context.safetyChecks.preflight=true')
184
184
  .option('--safety-stop-channel', 'Set context.safetyChecks.stopChannel=true')
185
185
  .option('--check-adapter', 'Run adapter readiness check for robot/hybrid scene')
186
+ .option('--moqui-config <path>', 'Path to moqui-adapter.json config file for runtime bindings')
186
187
  .option('--binding-plugin-dir <path>', 'Binding plugin directory for runtime handler loading')
187
188
  .option('--binding-plugin-manifest <path>', 'Binding plugin manifest JSON path')
188
189
  .option('--no-binding-plugin-auto-discovery', 'Disable runtime binding plugin auto-discovery under .kiro')
@@ -226,6 +227,7 @@ function registerSceneCommands(program) {
226
227
  .option('--allow-hybrid-commit', 'Set context.allowHybridCommit=true')
227
228
  .option('--safety-preflight', 'Set context.safetyChecks.preflight=true')
228
229
  .option('--safety-stop-channel', 'Set context.safetyChecks.stopChannel=true')
230
+ .option('--moqui-config <path>', 'Path to moqui-adapter.json config file for runtime bindings')
229
231
  .option('--binding-plugin-dir <path>', 'Binding plugin directory for runtime handler loading')
230
232
  .option('--binding-plugin-manifest <path>', 'Binding plugin manifest JSON path')
231
233
  .option('--no-binding-plugin-auto-discovery', 'Disable runtime binding plugin auto-discovery under .kiro')
@@ -929,6 +931,7 @@ function normalizeRunOptions(options = {}) {
929
931
  ...context,
930
932
  mode: options.mode || 'dry_run',
931
933
  traceId: options.traceId,
934
+ moquiConfig: options.moquiConfig,
932
935
  bindingPluginDir: options.bindingPluginDir,
933
936
  bindingPluginManifest: options.bindingPluginManifest,
934
937
  bindingPluginAutoDiscovery: options.bindingPluginAutoDiscovery !== false,
@@ -952,6 +955,7 @@ function normalizeDoctorOptions(options = {}) {
952
955
  mode: options.mode || 'dry_run',
953
956
  traceId: options.traceId,
954
957
  checkAdapter: options.checkAdapter === true,
958
+ moquiConfig: options.moquiConfig,
955
959
  bindingPluginDir: options.bindingPluginDir,
956
960
  bindingPluginManifest: options.bindingPluginManifest,
957
961
  bindingPluginAutoDiscovery: options.bindingPluginAutoDiscovery !== false,
@@ -1226,6 +1230,10 @@ function validateRunOptions(options) {
1226
1230
  return '--binding-plugin-manifest must be a non-empty path';
1227
1231
  }
1228
1232
 
1233
+ if (options.moquiConfig && (typeof options.moquiConfig !== 'string' || String(options.moquiConfig).trim().length === 0)) {
1234
+ return '--moqui-config must be a non-empty path';
1235
+ }
1236
+
1229
1237
  return null;
1230
1238
  }
1231
1239
 
@@ -1252,6 +1260,10 @@ function validateDoctorOptions(options) {
1252
1260
  return '--binding-plugin-manifest must be a non-empty path';
1253
1261
  }
1254
1262
 
1263
+ if (options.moquiConfig && (typeof options.moquiConfig !== 'string' || String(options.moquiConfig).trim().length === 0)) {
1264
+ return '--moqui-config must be a non-empty path';
1265
+ }
1266
+
1255
1267
  return null;
1256
1268
  }
1257
1269
 
@@ -6478,6 +6490,7 @@ async function runSceneDoctorCommand(rawOptions = {}, dependencies = {}) {
6478
6490
 
6479
6491
  const runtimeExecutor = dependencies.runtimeExecutor || new RuntimeExecutor({
6480
6492
  projectRoot,
6493
+ moquiConfigPath: options.moquiConfig,
6481
6494
  bindingPluginDir: options.bindingPluginDir,
6482
6495
  bindingPluginManifest: options.bindingPluginManifest,
6483
6496
  bindingPluginAutoDiscovery: options.bindingPluginAutoDiscovery,
@@ -8234,6 +8247,7 @@ async function runSceneCommand(rawOptions = {}, dependencies = {}) {
8234
8247
 
8235
8248
  const runtimeExecutor = dependencies.runtimeExecutor || new RuntimeExecutor({
8236
8249
  projectRoot,
8250
+ moquiConfigPath: options.moquiConfig,
8237
8251
  bindingPluginDir: options.bindingPluginDir,
8238
8252
  bindingPluginManifest: options.bindingPluginManifest,
8239
8253
  bindingPluginAutoDiscovery: options.bindingPluginAutoDiscovery,
@@ -463,6 +463,12 @@ class OrchestrationEngine extends EventEmitter {
463
463
  : 0;
464
464
  if (retryDelayMs > 0) {
465
465
  this._onRateLimitSignal();
466
+ this._updateStatusMonitorRateLimit({
467
+ specName,
468
+ retryCount,
469
+ retryDelayMs,
470
+ error: resolvedError,
471
+ });
466
472
  this.emit('spec:rate-limited', {
467
473
  specName,
468
474
  retryCount,
@@ -650,6 +656,15 @@ class OrchestrationEngine extends EventEmitter {
650
656
  this._baseMaxParallel = boundedMax;
651
657
  this._effectiveMaxParallel = boundedMax;
652
658
  this._rateLimitCooldownUntil = 0;
659
+ this._updateStatusMonitorParallelTelemetry({
660
+ adaptive: this._isAdaptiveParallelEnabled(),
661
+ maxParallel: boundedMax,
662
+ effectiveMaxParallel: boundedMax,
663
+ floor: Math.min(
664
+ boundedMax,
665
+ this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
666
+ ),
667
+ });
653
668
  }
654
669
 
655
670
  /**
@@ -659,20 +674,35 @@ class OrchestrationEngine extends EventEmitter {
659
674
  */
660
675
  _getEffectiveMaxParallel(maxParallel) {
661
676
  const boundedMax = this._toPositiveInteger(maxParallel, 1);
677
+ const floor = Math.min(
678
+ boundedMax,
679
+ this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
680
+ );
681
+
662
682
  if (!this._isAdaptiveParallelEnabled()) {
663
683
  this._baseMaxParallel = boundedMax;
684
+ this._effectiveMaxParallel = boundedMax;
685
+ this._updateStatusMonitorParallelTelemetry({
686
+ adaptive: false,
687
+ maxParallel: boundedMax,
688
+ effectiveMaxParallel: boundedMax,
689
+ floor,
690
+ });
664
691
  return boundedMax;
665
692
  }
666
693
 
667
694
  this._baseMaxParallel = boundedMax;
668
695
  this._maybeRecoverParallelLimit(boundedMax);
669
696
 
670
- const floor = Math.min(
671
- boundedMax,
672
- this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
673
- );
674
697
  const effective = this._toPositiveInteger(this._effectiveMaxParallel, boundedMax);
675
- return Math.max(floor, Math.min(boundedMax, effective));
698
+ const resolved = Math.max(floor, Math.min(boundedMax, effective));
699
+ this._updateStatusMonitorParallelTelemetry({
700
+ adaptive: true,
701
+ maxParallel: boundedMax,
702
+ effectiveMaxParallel: resolved,
703
+ floor,
704
+ });
705
+ return resolved;
676
706
  }
677
707
 
678
708
  /**
@@ -693,6 +723,14 @@ class OrchestrationEngine extends EventEmitter {
693
723
 
694
724
  if (next < current) {
695
725
  this._effectiveMaxParallel = next;
726
+ this._updateStatusMonitorParallelTelemetry({
727
+ event: 'throttled',
728
+ reason: 'rate-limit',
729
+ adaptive: true,
730
+ maxParallel: base,
731
+ effectiveMaxParallel: next,
732
+ floor,
733
+ });
696
734
  this.emit('parallel:throttled', {
697
735
  reason: 'rate-limit',
698
736
  previousMaxParallel: current,
@@ -730,6 +768,12 @@ class OrchestrationEngine extends EventEmitter {
730
768
  if (next > current) {
731
769
  this._effectiveMaxParallel = next;
732
770
  this._rateLimitCooldownUntil = this._getNow() + this._rateLimitCooldownMs;
771
+ this._updateStatusMonitorParallelTelemetry({
772
+ event: 'recovered',
773
+ adaptive: true,
774
+ maxParallel: boundedMax,
775
+ effectiveMaxParallel: next,
776
+ });
733
777
  this.emit('parallel:recovered', {
734
778
  previousMaxParallel: current,
735
779
  effectiveMaxParallel: next,
@@ -841,6 +885,40 @@ class OrchestrationEngine extends EventEmitter {
841
885
  return new Promise((resolve) => setTimeout(resolve, ms));
842
886
  }
843
887
 
888
+ /**
889
+ * Safely update StatusMonitor rate-limit telemetry.
890
+ *
891
+ * @param {object} payload
892
+ * @private
893
+ */
894
+ _updateStatusMonitorRateLimit(payload) {
895
+ const handler = this._statusMonitor && this._statusMonitor.recordRateLimitEvent;
896
+ if (typeof handler === 'function') {
897
+ try {
898
+ handler.call(this._statusMonitor, payload);
899
+ } catch (_err) {
900
+ // Non-fatal status telemetry update.
901
+ }
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Safely update StatusMonitor adaptive parallel telemetry.
907
+ *
908
+ * @param {object} payload
909
+ * @private
910
+ */
911
+ _updateStatusMonitorParallelTelemetry(payload) {
912
+ const handler = this._statusMonitor && this._statusMonitor.updateParallelTelemetry;
913
+ if (typeof handler === 'function') {
914
+ try {
915
+ handler.call(this._statusMonitor, payload);
916
+ } catch (_err) {
917
+ // Non-fatal status telemetry update.
918
+ }
919
+ }
920
+ }
921
+
844
922
  /**
845
923
  * Validate that all spec directories exist (Req 6.4).
846
924
  *
@@ -56,6 +56,37 @@ class StatusMonitor {
56
56
  * @type {Map<string, {status: string, batch: number, agentId: string|null, retryCount: number, error: string|null, turnCount: number}>}
57
57
  */
58
58
  this._specs = new Map();
59
+
60
+ /**
61
+ * Rate-limit telemetry summary.
62
+ * @type {{signalCount: number, retryCount: number, totalBackoffMs: number, lastSignalAt: string|null, lastSpecName: string|null, lastRetryCount: number, lastDelayMs: number, lastError: string|null}}
63
+ */
64
+ this._rateLimit = {
65
+ signalCount: 0,
66
+ retryCount: 0,
67
+ totalBackoffMs: 0,
68
+ lastSignalAt: null,
69
+ lastSpecName: null,
70
+ lastRetryCount: 0,
71
+ lastDelayMs: 0,
72
+ lastError: null,
73
+ };
74
+
75
+ /**
76
+ * Parallelism telemetry summary.
77
+ * @type {{adaptive: boolean|null, maxParallel: number|null, effectiveMaxParallel: number|null, floor: number|null, throttledCount: number, recoveredCount: number, lastReason: string|null, lastThrottledAt: string|null, lastRecoveredAt: string|null}}
78
+ */
79
+ this._parallel = {
80
+ adaptive: null,
81
+ maxParallel: null,
82
+ effectiveMaxParallel: null,
83
+ floor: null,
84
+ throttledCount: 0,
85
+ recoveredCount: 0,
86
+ lastReason: null,
87
+ lastThrottledAt: null,
88
+ lastRecoveredAt: null,
89
+ };
59
90
  }
60
91
 
61
92
  // ---------------------------------------------------------------------------
@@ -125,6 +156,80 @@ class StatusMonitor {
125
156
  }
126
157
  }
127
158
 
159
+ /**
160
+ * Record a rate-limit retry/backoff signal.
161
+ *
162
+ * @param {object} data
163
+ * @param {string} [data.specName]
164
+ * @param {number} [data.retryCount]
165
+ * @param {number} [data.retryDelayMs]
166
+ * @param {string} [data.error]
167
+ */
168
+ recordRateLimitEvent(data = {}) {
169
+ const delay = Number(data.retryDelayMs);
170
+ const retryCount = Number(data.retryCount);
171
+
172
+ this._rateLimit.signalCount++;
173
+ this._rateLimit.retryCount++;
174
+ this._rateLimit.totalBackoffMs += Number.isFinite(delay) && delay > 0 ? Math.round(delay) : 0;
175
+ this._rateLimit.lastSignalAt = new Date().toISOString();
176
+
177
+ if (typeof data.specName === 'string' && data.specName.trim()) {
178
+ this._rateLimit.lastSpecName = data.specName.trim();
179
+ }
180
+ if (Number.isFinite(retryCount) && retryCount >= 0) {
181
+ this._rateLimit.lastRetryCount = Math.floor(retryCount);
182
+ }
183
+ this._rateLimit.lastDelayMs = Number.isFinite(delay) && delay > 0 ? Math.round(delay) : 0;
184
+ if (data.error !== undefined && data.error !== null) {
185
+ this._rateLimit.lastError = `${data.error}`;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Update adaptive parallel telemetry.
191
+ *
192
+ * @param {object} data
193
+ * @param {'throttled'|'recovered'} [data.event]
194
+ * @param {string} [data.reason]
195
+ * @param {number} [data.maxParallel]
196
+ * @param {number} [data.effectiveMaxParallel]
197
+ * @param {number} [data.floor]
198
+ * @param {boolean} [data.adaptive]
199
+ */
200
+ updateParallelTelemetry(data = {}) {
201
+ if (typeof data.adaptive === 'boolean') {
202
+ this._parallel.adaptive = data.adaptive;
203
+ }
204
+
205
+ const maxParallel = Number(data.maxParallel);
206
+ if (Number.isFinite(maxParallel) && maxParallel > 0) {
207
+ this._parallel.maxParallel = Math.floor(maxParallel);
208
+ }
209
+
210
+ const effective = Number(data.effectiveMaxParallel);
211
+ if (Number.isFinite(effective) && effective > 0) {
212
+ this._parallel.effectiveMaxParallel = Math.floor(effective);
213
+ }
214
+
215
+ const floor = Number(data.floor);
216
+ if (Number.isFinite(floor) && floor > 0) {
217
+ this._parallel.floor = Math.floor(floor);
218
+ }
219
+
220
+ if (typeof data.reason === 'string' && data.reason.trim()) {
221
+ this._parallel.lastReason = data.reason.trim();
222
+ }
223
+
224
+ if (data.event === 'throttled') {
225
+ this._parallel.throttledCount++;
226
+ this._parallel.lastThrottledAt = new Date().toISOString();
227
+ } else if (data.event === 'recovered') {
228
+ this._parallel.recoveredCount++;
229
+ this._parallel.lastRecoveredAt = new Date().toISOString();
230
+ }
231
+ }
232
+
128
233
  /**
129
234
  * Set the overall orchestration state.
130
235
  *
@@ -219,6 +324,8 @@ class StatusMonitor {
219
324
  currentBatch: this._currentBatch,
220
325
  totalBatches: this._totalBatches,
221
326
  specs,
327
+ rateLimit: { ...this._rateLimit },
328
+ parallel: { ...this._parallel },
222
329
  };
223
330
  }
224
331
 
@@ -1,4 +1,6 @@
1
- const DEFAULT_READINESS_SUCCESS = {
1
+ const { createMoquiAdapterHandler } = require('./moqui-adapter');
2
+
3
+ const DEFAULT_READINESS_SUCCESS = {
2
4
  passed: true,
3
5
  reason: 'default-ready'
4
6
  };
@@ -130,6 +132,12 @@ function normalizeReadinessResult(rawResult, fallbackName) {
130
132
 
131
133
  class BindingRegistry {
132
134
  constructor(options = {}) {
135
+ this.projectRoot = options.projectRoot || process.cwd();
136
+ this.moquiConfigPath = typeof options.moquiConfigPath === 'string' && options.moquiConfigPath.trim().length > 0
137
+ ? options.moquiConfigPath.trim()
138
+ : undefined;
139
+ this.useMoquiAdapter = options.useMoquiAdapter !== false;
140
+
133
141
  this.handlers = [];
134
142
  this.fallbackHandler = {
135
143
  id: 'builtin.default',
@@ -158,6 +166,14 @@ class BindingRegistry {
158
166
  }
159
167
 
160
168
  registerDefaultHandlers() {
169
+ if (this.useMoquiAdapter) {
170
+ this.register(createMoquiAdapterHandler({
171
+ projectRoot: this.projectRoot,
172
+ configPath: this.moquiConfigPath,
173
+ allowSpecErpFallback: true
174
+ }));
175
+ }
176
+
161
177
  this.register({
162
178
  id: 'builtin.erp-sim',
163
179
  match: { refPrefix: 'spec.erp.' },
@@ -12,6 +12,19 @@ const DEFAULT_CONFIG_FILENAME = 'moqui-adapter.json';
12
12
  const ENTITY_OPERATIONS = ['list', 'get', 'create', 'update', 'delete'];
13
13
  const SERVICE_OPERATIONS = ['invoke', 'async', 'job-status'];
14
14
 
15
+ function resolveAdapterConfigPath(configPath, projectRoot) {
16
+ if (configPath) {
17
+ return path.resolve(projectRoot || process.cwd(), configPath);
18
+ }
19
+
20
+ return path.resolve(projectRoot || process.cwd(), DEFAULT_CONFIG_FILENAME);
21
+ }
22
+
23
+ function hasAdapterConfigFile(configPath, projectRoot) {
24
+ const resolvedPath = resolveAdapterConfigPath(configPath, projectRoot);
25
+ return fs.pathExistsSync(resolvedPath);
26
+ }
27
+
15
28
  /**
16
29
  * Load and validate adapter config from file.
17
30
  * @param {string} [configPath] - Path to moqui-adapter.json
@@ -19,9 +32,7 @@ const SERVICE_OPERATIONS = ['invoke', 'async', 'job-status'];
19
32
  * @returns {{ config: Object, error?: string }}
20
33
  */
21
34
  function loadAdapterConfig(configPath, projectRoot) {
22
- const resolvedPath = configPath
23
- ? path.resolve(projectRoot || process.cwd(), configPath)
24
- : path.resolve(projectRoot || process.cwd(), DEFAULT_CONFIG_FILENAME);
35
+ const resolvedPath = resolveAdapterConfigPath(configPath, projectRoot);
25
36
 
26
37
  let rawContent;
27
38
 
@@ -404,6 +415,8 @@ function buildHttpRequest(descriptor, payload = {}) {
404
415
  */
405
416
  function createMoquiAdapterHandler(options = {}) {
406
417
  const HANDLER_ID = 'moqui.adapter';
418
+ const allowSpecErpFallback = options.allowSpecErpFallback !== false;
419
+ const strictMatch = options.strictMatch === true;
407
420
 
408
421
  let client = options.client || null;
409
422
  let configLoaded = false;
@@ -441,12 +454,36 @@ function createMoquiAdapterHandler(options = {}) {
441
454
  return { client };
442
455
  }
443
456
 
457
+ function shouldHandleBindingRef(bindingRef) {
458
+ const ref = String(bindingRef || '').trim();
459
+
460
+ if (!ref) {
461
+ return false;
462
+ }
463
+
464
+ if (ref.startsWith('moqui.')) {
465
+ return true;
466
+ }
467
+
468
+ if (!ref.startsWith('spec.erp.')) {
469
+ return false;
470
+ }
471
+
472
+ if (strictMatch || !allowSpecErpFallback) {
473
+ return true;
474
+ }
475
+
476
+ if (client) {
477
+ return true;
478
+ }
479
+
480
+ return hasAdapterConfigFile(options.configPath, options.projectRoot);
481
+ }
482
+
444
483
  return {
445
484
  id: HANDLER_ID,
446
485
 
447
- match: {
448
- refPrefix: ['spec.erp.', 'moqui.']
449
- },
486
+ match: (node = {}) => shouldHandleBindingRef(node.binding_ref || node.ref),
450
487
 
451
488
  /**
452
489
  * Execute a binding node against the Moqui REST API.
@@ -571,6 +608,8 @@ module.exports = {
571
608
  mapMoquiResponseToResult,
572
609
  buildHttpRequest,
573
610
  createMoquiAdapterHandler,
611
+ resolveAdapterConfigPath,
612
+ hasAdapterConfigFile,
574
613
  // Exported for testing
575
614
  applyConfigDefaults,
576
615
  DEFAULT_TIMEOUT,
@@ -5,6 +5,7 @@ const { URL } = require('url');
5
5
  const DEFAULT_TIMEOUT = 30000;
6
6
  const DEFAULT_RETRY_COUNT = 2;
7
7
  const DEFAULT_RETRY_DELAY = 1000;
8
+ const DEFAULT_MAX_RETRY_DELAY = 30000;
8
9
 
9
10
  const RETRYABLE_NETWORK_ERRORS = [
10
11
  'ECONNREFUSED',
@@ -26,7 +27,57 @@ function isRetryableNetworkError(error) {
26
27
  }
27
28
 
28
29
  function isRetryableStatusCode(statusCode) {
29
- return statusCode >= 500 && statusCode <= 599;
30
+ return statusCode === 429 || (statusCode >= 500 && statusCode <= 599);
31
+ }
32
+
33
+ function parseRetryAfterMs(headers = {}) {
34
+ if (!headers || typeof headers !== 'object') {
35
+ return null;
36
+ }
37
+
38
+ const retryAfterValue = headers['retry-after'] || headers['Retry-After'];
39
+
40
+ if (retryAfterValue === undefined || retryAfterValue === null) {
41
+ return null;
42
+ }
43
+
44
+ const asNumber = Number(retryAfterValue);
45
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
46
+ return Math.floor(asNumber * 1000);
47
+ }
48
+
49
+ const asDateMs = Date.parse(String(retryAfterValue));
50
+ if (!Number.isFinite(asDateMs)) {
51
+ return null;
52
+ }
53
+
54
+ return Math.max(0, asDateMs - Date.now());
55
+ }
56
+
57
+ function clampRetryDelay(ms, maxRetryDelay) {
58
+ const parsed = Number(ms);
59
+
60
+ if (!Number.isFinite(parsed)) {
61
+ return 0;
62
+ }
63
+
64
+ const bounded = Math.max(0, parsed);
65
+ if (!Number.isFinite(maxRetryDelay) || maxRetryDelay <= 0) {
66
+ return Math.floor(bounded);
67
+ }
68
+
69
+ return Math.floor(Math.min(maxRetryDelay, bounded));
70
+ }
71
+
72
+ function computeRetryDelayMs(attempt, baseRetryDelay, retryAfterMs, maxRetryDelay) {
73
+ if (Number.isFinite(retryAfterMs) && retryAfterMs >= 0) {
74
+ return clampRetryDelay(retryAfterMs, maxRetryDelay);
75
+ }
76
+
77
+ const exponent = Math.max(0, attempt);
78
+ const backoff = Number(baseRetryDelay) * Math.pow(2, exponent);
79
+
80
+ return clampRetryDelay(backoff, maxRetryDelay);
30
81
  }
31
82
 
32
83
  function delay(ms) {
@@ -66,7 +117,8 @@ class MoquiClient {
66
117
  credentials: config.credentials || {},
67
118
  timeout: typeof config.timeout === 'number' ? config.timeout : DEFAULT_TIMEOUT,
68
119
  retryCount: typeof config.retryCount === 'number' ? config.retryCount : DEFAULT_RETRY_COUNT,
69
- retryDelay: typeof config.retryDelay === 'number' ? config.retryDelay : DEFAULT_RETRY_DELAY
120
+ retryDelay: typeof config.retryDelay === 'number' ? config.retryDelay : DEFAULT_RETRY_DELAY,
121
+ maxRetryDelay: typeof config.maxRetryDelay === 'number' ? config.maxRetryDelay : DEFAULT_MAX_RETRY_DELAY
70
122
  };
71
123
 
72
124
  this.accessToken = null;
@@ -338,10 +390,6 @@ class MoquiClient {
338
390
  const maxAttempts = this.config.retryCount + 1;
339
391
 
340
392
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
341
- if (attempt > 0) {
342
- await delay(this.config.retryDelay);
343
- }
344
-
345
393
  try {
346
394
  // Update auth header in case token was refreshed
347
395
  if (this.accessToken) {
@@ -381,16 +429,36 @@ class MoquiClient {
381
429
  };
382
430
  }
383
431
 
384
- // Retry on 5xx
432
+ // Retry on 429 and 5xx
385
433
  if (isRetryableStatusCode(response.statusCode)) {
434
+ const isRateLimited = response.statusCode === 429;
435
+
386
436
  lastError = {
387
437
  success: false,
388
438
  error: {
389
- code: 'MOQUI_ERROR',
390
- message: `Server error: ${response.statusCode}`,
439
+ code: isRateLimited ? 'RATE_LIMITED' : 'MOQUI_ERROR',
440
+ message: isRateLimited
441
+ ? `Rate limited: ${response.statusCode}`
442
+ : `Server error: ${response.statusCode}`,
391
443
  details: response.body
392
444
  }
393
445
  };
446
+
447
+ if (attempt >= maxAttempts - 1) {
448
+ break;
449
+ }
450
+
451
+ const retryDelayMs = computeRetryDelayMs(
452
+ attempt,
453
+ this.config.retryDelay,
454
+ parseRetryAfterMs(response.headers),
455
+ this.config.maxRetryDelay
456
+ );
457
+
458
+ if (retryDelayMs > 0) {
459
+ await delay(retryDelayMs);
460
+ }
461
+
394
462
  continue;
395
463
  }
396
464
 
@@ -415,6 +483,22 @@ class MoquiClient {
415
483
  message: `Network error: ${error.message} (${fullUrl})`
416
484
  }
417
485
  };
486
+
487
+ if (attempt >= maxAttempts - 1) {
488
+ break;
489
+ }
490
+
491
+ const retryDelayMs = computeRetryDelayMs(
492
+ attempt,
493
+ this.config.retryDelay,
494
+ null,
495
+ this.config.maxRetryDelay
496
+ );
497
+
498
+ if (retryDelayMs > 0) {
499
+ await delay(retryDelayMs);
500
+ }
501
+
418
502
  continue;
419
503
  }
420
504
 
@@ -1131,44 +1131,15 @@ function analyzeResources(discovery, options = {}) {
1131
1131
  return results;
1132
1132
  }
1133
1133
 
1134
- // ─── Scene Manifest Generation ────────────────────────────────────
1135
-
1136
- /**
1137
- * Generate a scene manifest object for a pattern match.
1138
- * Produces a manifest with correct apiVersion, kind, bindings, model_scope,
1139
- * and governance_contract based on the pattern type.
1140
- *
1141
- * Pattern rules for bindings:
1142
- * - "crud": 5 bindings (list, get = query; create, update, delete = mutation with side_effect)
1143
- * - "query": 2 bindings (list, get = query)
1144
- * - "workflow": service invoke bindings (type: 'invoke', ref from bindingRefs)
1145
- *
1146
- * Governance rules:
1147
- * - "query": risk_level "low", approval.required false, no idempotency
1148
- * - "crud"/"workflow": risk_level "medium", approval.required true, idempotency.required true
1149
- *
1150
- * @param {PatternMatch} match - Matched pattern
1151
- * @returns {Object|null} Scene manifest object, or null for invalid input
1152
- */
1153
- function generateSceneManifest(match) {
1154
- if (!match || !match.pattern || !match.primaryResource) {
1155
- return null;
1156
- }
1157
-
1134
+ function buildBaseBindings(match) {
1158
1135
  const pattern = match.pattern;
1159
- const primaryResource = match.primaryResource;
1160
- const packageName = derivePackageName(match);
1161
-
1162
- // Build bindings based on pattern type
1163
1136
  const bindings = [];
1164
1137
 
1165
1138
  if (pattern === 'crud' || pattern === 'query') {
1166
- // Entity-based patterns use bindingRefs from the match
1167
1139
  const primaryEntity = Array.isArray(match.entities) && match.entities.length > 0
1168
1140
  ? match.entities[0]
1169
- : primaryResource;
1141
+ : match.primaryResource;
1170
1142
 
1171
- // Query bindings (list, get)
1172
1143
  bindings.push({
1173
1144
  type: 'query',
1174
1145
  ref: `moqui.${primaryEntity}.list`,
@@ -1183,7 +1154,6 @@ function generateSceneManifest(match) {
1183
1154
  });
1184
1155
 
1185
1156
  if (pattern === 'crud') {
1186
- // Mutation bindings (create, update, delete)
1187
1157
  bindings.push({
1188
1158
  type: 'mutation',
1189
1159
  ref: `moqui.${primaryEntity}.create`,
@@ -1207,9 +1177,7 @@ function generateSceneManifest(match) {
1207
1177
  });
1208
1178
  }
1209
1179
  } else if (pattern === 'workflow') {
1210
- // Workflow patterns use service invoke bindings from bindingRefs
1211
1180
  const refs = Array.isArray(match.bindingRefs) ? match.bindingRefs : [];
1212
-
1213
1181
  for (const ref of refs) {
1214
1182
  bindings.push({
1215
1183
  type: 'invoke',
@@ -1220,37 +1188,324 @@ function generateSceneManifest(match) {
1220
1188
  }
1221
1189
  }
1222
1190
 
1223
- // Build model_scope from match
1224
- const modelScope = match.modelScope || { read: [], write: [] };
1191
+ return bindings;
1192
+ }
1225
1193
 
1226
- // Build governance_contract based on pattern type
1227
- let governanceContract;
1194
+ function deriveBindingIntent(binding, primaryResource) {
1195
+ const ref = String(binding.ref || '');
1228
1196
 
1229
- if (pattern === 'query') {
1230
- governanceContract = {
1231
- risk_level: 'low',
1232
- approval: {
1233
- required: false
1234
- }
1197
+ if (ref.endsWith('.list')) {
1198
+ return `List ${primaryResource} records from Moqui`;
1199
+ }
1200
+
1201
+ if (ref.endsWith('.get')) {
1202
+ return `Retrieve a single ${primaryResource} record`;
1203
+ }
1204
+
1205
+ if (ref.endsWith('.create')) {
1206
+ return `Create a new ${primaryResource} record`;
1207
+ }
1208
+
1209
+ if (ref.endsWith('.update')) {
1210
+ return `Update an existing ${primaryResource} record`;
1211
+ }
1212
+
1213
+ if (ref.endsWith('.delete')) {
1214
+ return `Delete an existing ${primaryResource} record`;
1215
+ }
1216
+
1217
+ if (ref.endsWith('.invoke')) {
1218
+ return `Invoke workflow service for ${primaryResource}`;
1219
+ }
1220
+
1221
+ return `Execute ${ref}`;
1222
+ }
1223
+
1224
+ function deriveBindingPreconditions(binding, previousRef) {
1225
+ const checks = ['Moqui adapter authentication is valid'];
1226
+
1227
+ if (binding.type === 'query') {
1228
+ checks.push('Read scope permits this query');
1229
+ } else if (binding.type === 'mutation') {
1230
+ checks.push('Input payload validation passed');
1231
+ } else if (binding.type === 'invoke') {
1232
+ checks.push('Workflow input contract is satisfied');
1233
+ }
1234
+
1235
+ if (previousRef) {
1236
+ checks.push(`Dependency ${previousRef} completed successfully`);
1237
+ }
1238
+
1239
+ return checks;
1240
+ }
1241
+
1242
+ function deriveBindingPostconditions(binding) {
1243
+ if (binding.type === 'query') {
1244
+ return ['Query result is available for downstream composition'];
1245
+ }
1246
+
1247
+ if (binding.type === 'mutation') {
1248
+ return ['Mutation result is captured and write scope is consistent'];
1249
+ }
1250
+
1251
+ if (binding.type === 'invoke') {
1252
+ return ['Workflow step output is captured for downstream execution'];
1253
+ }
1254
+
1255
+ return ['Binding execution result is available'];
1256
+ }
1257
+
1258
+ function addBindingSemantics(baseBindings, primaryResource) {
1259
+ const bindings = [];
1260
+
1261
+ for (let i = 0; i < baseBindings.length; i++) {
1262
+ const base = baseBindings[i];
1263
+ const previous = i > 0 ? baseBindings[i - 1] : null;
1264
+ const binding = {
1265
+ ...base,
1266
+ intent: deriveBindingIntent(base, primaryResource),
1267
+ preconditions: deriveBindingPreconditions(base, previous ? previous.ref : null),
1268
+ postconditions: deriveBindingPostconditions(base)
1235
1269
  };
1270
+
1271
+ if (previous && previous.ref) {
1272
+ binding.depends_on = previous.ref;
1273
+ }
1274
+
1275
+ bindings.push(binding);
1276
+ }
1277
+
1278
+ return bindings;
1279
+ }
1280
+
1281
+ function buildDataLineage(bindings, pattern, primaryResource) {
1282
+ if (!Array.isArray(bindings) || bindings.length === 0) {
1283
+ return {
1284
+ sources: [],
1285
+ transforms: [],
1286
+ sinks: []
1287
+ };
1288
+ }
1289
+
1290
+ const firstRef = bindings[0].ref;
1291
+ const lastRef = bindings[bindings.length - 1].ref;
1292
+ const sourceField = `${toKebabCase(primaryResource)}Id`;
1293
+
1294
+ const transforms = [
1295
+ {
1296
+ operation: 'normalizeInput',
1297
+ description: `Normalize ${primaryResource} request payload for template execution`
1298
+ }
1299
+ ];
1300
+
1301
+ if (pattern === 'workflow') {
1302
+ transforms.push({
1303
+ operation: 'orchestrateWorkflow',
1304
+ description: `Coordinate service chain for ${primaryResource}`
1305
+ });
1306
+ } else if (pattern === 'crud') {
1307
+ transforms.push({
1308
+ operation: 'applyMutationGuard',
1309
+ description: `Apply mutation and idempotency guard for ${primaryResource}`
1310
+ });
1236
1311
  } else {
1237
- // crud and workflow
1238
- const gov = match.governance || {};
1239
- const idempotencyKey = gov.idempotencyKey || deriveIdempotencyKey(
1240
- Array.isArray(match.entities) && match.entities.length > 0
1241
- ? match.entities[0]
1242
- : primaryResource
1243
- );
1244
-
1245
- governanceContract = {
1246
- risk_level: 'medium',
1247
- approval: {
1248
- required: true
1249
- },
1250
- idempotency: {
1251
- required: true,
1252
- key: idempotencyKey
1312
+ transforms.push({
1313
+ operation: 'projectQueryResult',
1314
+ description: `Project query result set for ${primaryResource}`
1315
+ });
1316
+ }
1317
+
1318
+ return {
1319
+ sources: [
1320
+ {
1321
+ ref: firstRef,
1322
+ fields: [sourceField, 'statusId']
1323
+ }
1324
+ ],
1325
+ transforms,
1326
+ sinks: [
1327
+ {
1328
+ ref: lastRef,
1329
+ fields: [sourceField, 'statusId']
1253
1330
  }
1331
+ ]
1332
+ };
1333
+ }
1334
+
1335
+ function buildEntityRefs(match, primaryResource) {
1336
+ const refs = Array.isArray(match.entities) && match.entities.length > 0
1337
+ ? match.entities.filter(Boolean)
1338
+ : [primaryResource];
1339
+
1340
+ return refs.map((entity, index) => ({
1341
+ id: String(entity),
1342
+ type: index === 0 ? 'primary' : 'related'
1343
+ }));
1344
+ }
1345
+
1346
+ function buildEntityRelations(entityRefs) {
1347
+ if (!Array.isArray(entityRefs) || entityRefs.length === 0) {
1348
+ return [];
1349
+ }
1350
+
1351
+ const relations = [];
1352
+ const primaryId = entityRefs[0].id;
1353
+
1354
+ for (let i = 1; i < entityRefs.length; i++) {
1355
+ relations.push({
1356
+ source: primaryId,
1357
+ target: entityRefs[i].id,
1358
+ type: 'composes'
1359
+ });
1360
+ }
1361
+
1362
+ if (relations.length === 0) {
1363
+ relations.push({
1364
+ source: primaryId,
1365
+ target: 'metadata_view',
1366
+ type: 'produces'
1367
+ });
1368
+ }
1369
+
1370
+ return relations;
1371
+ }
1372
+
1373
+ function buildBusinessRules(pattern, bindings, primaryResource) {
1374
+ const firstRef = bindings[0] ? bindings[0].ref : null;
1375
+ const lastRef = bindings[bindings.length - 1] ? bindings[bindings.length - 1].ref : null;
1376
+
1377
+ const rules = [
1378
+ {
1379
+ id: `rule.${toKebabCase(primaryResource)}.binding-order`,
1380
+ description: `Bindings for ${primaryResource} must execute in declared dependency order`,
1381
+ bind_to: firstRef,
1382
+ status: 'enforced'
1383
+ }
1384
+ ];
1385
+
1386
+ if (pattern === 'query') {
1387
+ rules.push({
1388
+ id: `rule.${toKebabCase(primaryResource)}.read-only`,
1389
+ description: `${primaryResource} query template must remain side-effect free`,
1390
+ bind_to: lastRef,
1391
+ status: 'active'
1392
+ });
1393
+ } else {
1394
+ rules.push({
1395
+ id: `rule.${toKebabCase(primaryResource)}.approval-or-idempotency`,
1396
+ description: `${primaryResource} template must enforce approval or idempotency guard`,
1397
+ bind_to: lastRef,
1398
+ status: 'active'
1399
+ });
1400
+ }
1401
+
1402
+ return rules;
1403
+ }
1404
+
1405
+ function buildDecisionLogic(pattern, bindings, primaryResource) {
1406
+ const lastRef = bindings[bindings.length - 1] ? bindings[bindings.length - 1].ref : null;
1407
+ const riskDecision = pattern === 'query'
1408
+ ? 'Use low-risk dry-run defaults for query execution'
1409
+ : 'Use guarded execution with approval and retry policies';
1410
+
1411
+ return [
1412
+ {
1413
+ id: `decision.${toKebabCase(primaryResource)}.risk-strategy`,
1414
+ description: riskDecision,
1415
+ bind_to: lastRef,
1416
+ status: 'resolved',
1417
+ automated: true
1418
+ },
1419
+ {
1420
+ id: `decision.${toKebabCase(primaryResource)}.retry-strategy`,
1421
+ description: 'Apply timeout/retry profile derived from template contract',
1422
+ bind_to: lastRef,
1423
+ status: 'resolved',
1424
+ automated: true
1425
+ }
1426
+ ];
1427
+ }
1428
+
1429
+ function buildAgentHints(pattern, primaryResource, bindings) {
1430
+ const complexity = pattern === 'query' ? 'low' : 'medium';
1431
+ const baseDuration = pattern === 'query' ? 1800 : 3000;
1432
+ const permissions = pattern === 'query'
1433
+ ? ['moqui.read']
1434
+ : ['moqui.read', 'moqui.write'];
1435
+
1436
+ return {
1437
+ summary: `${pattern.toUpperCase()} template extracted for ${primaryResource} with Moqui-aware ontology`,
1438
+ complexity,
1439
+ estimated_duration_ms: baseDuration + (bindings.length * 150),
1440
+ required_permissions: permissions,
1441
+ suggested_sequence: bindings.map((binding) => binding.ref),
1442
+ rollback_strategy: pattern === 'query'
1443
+ ? 'Re-run query with previous filters'
1444
+ : 'Reconcile idempotency key and rollback to pre-mutation snapshot'
1445
+ };
1446
+ }
1447
+
1448
+ // ─── Scene Manifest Generation ────────────────────────────────────
1449
+
1450
+ /**
1451
+ * Generate a scene manifest object for a pattern match.
1452
+ * Produces a manifest with correct apiVersion, kind, bindings, model_scope,
1453
+ * and governance_contract based on the pattern type.
1454
+ *
1455
+ * Pattern rules for bindings:
1456
+ * - "crud": 5 bindings (list, get = query; create, update, delete = mutation with side_effect)
1457
+ * - "query": 2 bindings (list, get = query)
1458
+ * - "workflow": service invoke bindings (type: 'invoke', ref from bindingRefs)
1459
+ *
1460
+ * Governance rules:
1461
+ * - "query": risk_level "low", approval.required false, no idempotency
1462
+ * - "crud"/"workflow": risk_level "medium", approval.required true, idempotency.required true
1463
+ *
1464
+ * @param {PatternMatch} match - Matched pattern
1465
+ * @returns {Object|null} Scene manifest object, or null for invalid input
1466
+ */
1467
+ function generateSceneManifest(match) {
1468
+ if (!match || !match.pattern || !match.primaryResource) {
1469
+ return null;
1470
+ }
1471
+
1472
+ const pattern = match.pattern;
1473
+ const primaryResource = match.primaryResource;
1474
+ const packageName = derivePackageName(match);
1475
+ const gov = match.governance || {};
1476
+
1477
+ const baseBindings = buildBaseBindings(match);
1478
+ const bindings = addBindingSemantics(baseBindings, primaryResource);
1479
+
1480
+ // Build model_scope from match
1481
+ const modelScope = match.modelScope || { read: [], write: [] };
1482
+
1483
+ // Build governance_contract based on pattern type
1484
+ const riskLevel = gov.riskLevel || (pattern === 'query' ? 'low' : 'medium');
1485
+ const approvalRequired = gov.approvalRequired !== undefined
1486
+ ? gov.approvalRequired
1487
+ : (pattern !== 'query');
1488
+ const idempotencyRequired = gov.idempotencyRequired !== undefined
1489
+ ? gov.idempotencyRequired
1490
+ : (pattern !== 'query');
1491
+ const idempotencyKey = gov.idempotencyKey || deriveIdempotencyKey(
1492
+ Array.isArray(match.entities) && match.entities.length > 0
1493
+ ? match.entities[0]
1494
+ : primaryResource
1495
+ );
1496
+
1497
+ const governanceContract = {
1498
+ risk_level: riskLevel,
1499
+ approval: {
1500
+ required: approvalRequired
1501
+ },
1502
+ data_lineage: buildDataLineage(bindings, pattern, primaryResource)
1503
+ };
1504
+
1505
+ if (idempotencyRequired) {
1506
+ governanceContract.idempotency = {
1507
+ required: true,
1508
+ key: idempotencyKey
1254
1509
  };
1255
1510
  }
1256
1511
 
@@ -1311,6 +1566,41 @@ function generatePackageContract(match) {
1311
1566
  const primaryResource = match.primaryResource;
1312
1567
  const packageName = derivePackageName(match);
1313
1568
  const gov = match.governance || {};
1569
+ const baseBindings = buildBaseBindings(match);
1570
+ const bindings = addBindingSemantics(baseBindings, primaryResource);
1571
+ const riskLevel = gov.riskLevel || (pattern === 'query' ? 'low' : 'medium');
1572
+ const approvalRequired = gov.approvalRequired !== undefined
1573
+ ? gov.approvalRequired
1574
+ : (pattern !== 'query');
1575
+ const idempotencyRequired = gov.idempotencyRequired !== undefined
1576
+ ? gov.idempotencyRequired
1577
+ : (pattern !== 'query');
1578
+ const idempotencyKey = gov.idempotencyKey || deriveIdempotencyKey(
1579
+ Array.isArray(match.entities) && match.entities.length > 0
1580
+ ? match.entities[0]
1581
+ : primaryResource
1582
+ );
1583
+ const entityRefs = buildEntityRefs(match, primaryResource);
1584
+ const relations = buildEntityRelations(entityRefs);
1585
+ const hasMetadataView = entityRefs.some((entity) => entity.id === 'metadata_view');
1586
+
1587
+ if (!hasMetadataView) {
1588
+ entityRefs.push({ id: 'metadata_view', type: 'projection' });
1589
+ }
1590
+
1591
+ const hasMetadataRelation = relations.some((relation) => (
1592
+ relation.source === entityRefs[0].id
1593
+ && relation.target === 'metadata_view'
1594
+ && relation.type === 'produces'
1595
+ ));
1596
+
1597
+ if (!hasMetadataRelation) {
1598
+ relations.push({
1599
+ source: entityRefs[0].id,
1600
+ target: 'metadata_view',
1601
+ type: 'produces'
1602
+ });
1603
+ }
1314
1604
 
1315
1605
  // Derive summary based on pattern type
1316
1606
  let summary;
@@ -1325,11 +1615,22 @@ function generatePackageContract(match) {
1325
1615
  summary = `Template for ${primaryResource} extracted from Moqui ERP`;
1326
1616
  }
1327
1617
 
1328
- // Determine governance fields from match
1329
- const riskLevel = gov.riskLevel || (pattern === 'query' ? 'low' : 'medium');
1330
- const approvalRequired = gov.approvalRequired !== undefined
1331
- ? gov.approvalRequired
1332
- : (pattern !== 'query');
1618
+ const governanceContract = {
1619
+ risk_level: riskLevel,
1620
+ approval: {
1621
+ required: approvalRequired
1622
+ },
1623
+ data_lineage: buildDataLineage(bindings, pattern, primaryResource),
1624
+ business_rules: buildBusinessRules(pattern, bindings, primaryResource),
1625
+ decision_logic: buildDecisionLogic(pattern, bindings, primaryResource)
1626
+ };
1627
+
1628
+ if (idempotencyRequired) {
1629
+ governanceContract.idempotency = {
1630
+ required: true,
1631
+ key: idempotencyKey
1632
+ };
1633
+ }
1333
1634
 
1334
1635
  return {
1335
1636
  apiVersion: PACKAGE_API_VERSION,
@@ -1338,7 +1639,8 @@ function generatePackageContract(match) {
1338
1639
  group: 'kse.scene',
1339
1640
  name: packageName,
1340
1641
  version: '0.1.0',
1341
- summary
1642
+ summary,
1643
+ description: `${summary}. Includes ontology graph hints, lineage tracing, and AI execution metadata.`
1342
1644
  },
1343
1645
  compatibility: {
1344
1646
  kse_version: '>=1.39.0',
@@ -1367,8 +1669,24 @@ function generatePackageContract(match) {
1367
1669
  governance: {
1368
1670
  risk_level: riskLevel,
1369
1671
  approval_required: approvalRequired,
1672
+ approval: {
1673
+ required: approvalRequired
1674
+ },
1675
+ idempotency: {
1676
+ required: idempotencyRequired,
1677
+ key: idempotencyKey
1678
+ },
1370
1679
  rollback_supported: true
1371
- }
1680
+ },
1681
+ capability_contract: {
1682
+ bindings
1683
+ },
1684
+ governance_contract: governanceContract,
1685
+ ontology_model: {
1686
+ entities: entityRefs,
1687
+ relations
1688
+ },
1689
+ agent_hints: buildAgentHints(pattern, primaryResource, bindings)
1372
1690
  };
1373
1691
  }
1374
1692
 
@@ -15,7 +15,11 @@ class RuntimeExecutor {
15
15
  this.policyGate = options.policyGate || new PolicyGate();
16
16
  this.auditEmitter = options.auditEmitter || new AuditEmitter(options.projectRoot || process.cwd(), options.audit || {});
17
17
  this.evalBridge = options.evalBridge || new EvalBridge();
18
- this.bindingRegistry = options.bindingRegistry || new BindingRegistry();
18
+ this.bindingRegistry = options.bindingRegistry || new BindingRegistry({
19
+ projectRoot: options.projectRoot || process.cwd(),
20
+ moquiConfigPath: options.moquiConfigPath,
21
+ useMoquiAdapter: options.useMoquiAdapter
22
+ });
19
23
  this.bindingExecutor = typeof options.bindingExecutor === 'function' ? options.bindingExecutor : null;
20
24
  this.adapterReadinessChecker = options.adapterReadinessChecker || this.defaultAdapterReadinessChecker.bind(this);
21
25
  this.bindingPluginLoad = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiro-spec-engine",
3
- "version": "1.47.12",
3
+ "version": "1.47.15",
4
4
  "description": "kiro-spec-engine (kse) - A CLI tool and npm package for spec-driven development with AI coding assistants. NOT the Kiro IDE desktop application.",
5
5
  "main": "index.js",
6
6
  "bin": {