scene-capability-engine 3.0.8 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/docs/331-poc-adaptation-roadmap.md +21 -2
  3. package/docs/331-poc-dual-track-integration-guide.md +10 -6
  4. package/docs/331-poc-weekly-delivery-checklist.md +5 -0
  5. package/docs/README.md +6 -0
  6. package/docs/command-reference.md +262 -4
  7. package/docs/handoff-profile-integration-guide.md +88 -0
  8. package/docs/interactive-customization/331-poc-sce-integration-checklist.md +148 -0
  9. package/docs/interactive-customization/README.md +362 -0
  10. package/docs/interactive-customization/adapter-extension-contract.md +55 -0
  11. package/docs/interactive-customization/adapter-extension-contract.sample.json +59 -0
  12. package/docs/interactive-customization/adapter-extension-contract.schema.json +192 -0
  13. package/docs/interactive-customization/approval-role-policy-baseline.json +36 -0
  14. package/docs/interactive-customization/change-intent.schema.json +72 -0
  15. package/docs/interactive-customization/change-plan.sample.json +41 -0
  16. package/docs/interactive-customization/change-plan.schema.json +125 -0
  17. package/docs/interactive-customization/cross-industry-replication-guide.md +49 -0
  18. package/docs/interactive-customization/dialogue-governance-policy-baseline.json +49 -0
  19. package/docs/interactive-customization/domain-pack-extension-flow.md +71 -0
  20. package/docs/interactive-customization/execution-record.schema.json +62 -0
  21. package/docs/interactive-customization/governance-alert-playbook.md +51 -0
  22. package/docs/interactive-customization/governance-report-template.md +46 -0
  23. package/docs/interactive-customization/governance-threshold-baseline.json +14 -0
  24. package/docs/interactive-customization/guardrail-policy-baseline.json +27 -0
  25. package/docs/interactive-customization/high-risk-action-catalog.json +22 -0
  26. package/docs/interactive-customization/moqui-adapter-interface.md +40 -0
  27. package/docs/interactive-customization/moqui-context-provider.sample.json +72 -0
  28. package/docs/interactive-customization/moqui-copilot-context-contract.json +50 -0
  29. package/docs/interactive-customization/moqui-copilot-integration-guide.md +100 -0
  30. package/docs/interactive-customization/moqui-interactive-template-playbook.md +94 -0
  31. package/docs/interactive-customization/non-technical-usability-report.md +57 -0
  32. package/docs/interactive-customization/page-context.sample.json +73 -0
  33. package/docs/interactive-customization/page-context.schema.json +150 -0
  34. package/docs/interactive-customization/phase-acceptance-evidence.md +110 -0
  35. package/docs/interactive-customization/runtime-mode-policy-baseline.json +99 -0
  36. package/docs/moqui-template-core-library-playbook.md +28 -0
  37. package/docs/release-checklist.md +29 -4
  38. package/docs/security-governance-default-baseline.md +54 -0
  39. package/docs/starter-kit/README.md +50 -0
  40. package/docs/starter-kit/handoff-manifest.starter.json +32 -0
  41. package/docs/starter-kit/handoff-profile-ci.sample.yml +53 -0
  42. package/docs/starter-kit/release.workflow.sample.yml +41 -0
  43. package/docs/zh/README.md +12 -0
  44. package/lib/auto/moqui-recovery-sequence.js +62 -0
  45. package/lib/commands/auto.js +245 -34
  46. package/lib/commands/scene.js +867 -0
  47. package/lib/data/moqui-capability-lexicon.json +14 -1
  48. package/lib/interactive-customization/change-plan-gate-core.js +201 -0
  49. package/lib/interactive-customization/index.js +9 -0
  50. package/lib/interactive-customization/moqui-interactive-adapter.js +732 -0
  51. package/package.json +27 -2
@@ -0,0 +1,732 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const fs = require('fs-extra');
6
+ const { createMoquiAdapterHandler } = require('../scene-runtime/moqui-adapter');
7
+ const {
8
+ evaluatePlanGate,
9
+ DEFAULT_POLICY,
10
+ DEFAULT_CATALOG
11
+ } = require('./change-plan-gate-core');
12
+
13
+ const ADAPTER_TYPE = 'moqui-interactive-adapter';
14
+ const ADAPTER_VERSION = '1.0.0';
15
+
16
+ const DEFAULT_EXECUTION_RECORD_OUT = '.kiro/reports/interactive-execution-record.latest.json';
17
+ const DEFAULT_EXECUTION_LEDGER_OUT = '.kiro/reports/interactive-execution-ledger.jsonl';
18
+
19
+ const SUPPORTED_ACTION_TYPES = [
20
+ 'analysis_only',
21
+ 'workflow_approval_chain_change',
22
+ 'update_rule_threshold',
23
+ 'ui_form_field_adjust',
24
+ 'inventory_adjustment_bulk',
25
+ 'payment_rule_change',
26
+ 'bulk_delete_without_filter',
27
+ 'permission_grant_super_admin',
28
+ 'credential_export'
29
+ ];
30
+
31
+ function normalizeText(value) {
32
+ return `${value || ''}`.trim();
33
+ }
34
+
35
+ function resolvePath(projectRoot, filePath) {
36
+ return path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
37
+ }
38
+
39
+ function pickContextValue(context, fallback, field) {
40
+ if (context && context[field] != null) {
41
+ return context[field];
42
+ }
43
+ if (fallback && fallback[field] != null) {
44
+ return fallback[field];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function inferActionTypes(changeIntent, context) {
50
+ const goalText = normalizeText(changeIntent && changeIntent.business_goal).toLowerCase();
51
+ const moduleText = normalizeText(context && context.module).toLowerCase();
52
+ const entityText = normalizeText(context && context.entity).toLowerCase();
53
+ const merged = `${goalText} ${moduleText} ${entityText}`;
54
+
55
+ const actionTypes = [];
56
+ const pushType = (value) => {
57
+ if (!actionTypes.includes(value)) {
58
+ actionTypes.push(value);
59
+ }
60
+ };
61
+
62
+ if (/(approval|workflow|escalation|review)/.test(merged)) {
63
+ pushType('workflow_approval_chain_change');
64
+ }
65
+ if (/(rule|threshold|policy|strategy|decision)/.test(merged)) {
66
+ pushType('update_rule_threshold');
67
+ }
68
+ if (/(field|form|layout|ui|screen|page)/.test(merged)) {
69
+ pushType('ui_form_field_adjust');
70
+ }
71
+ if (/(inventory|stock|warehouse|reserve)/.test(merged)) {
72
+ pushType('inventory_adjustment_bulk');
73
+ }
74
+ if (/(payment|refund|billing)/.test(merged)) {
75
+ pushType('payment_rule_change');
76
+ }
77
+ if (/(delete|drop|remove all|truncate)/.test(merged)) {
78
+ pushType('bulk_delete_without_filter');
79
+ }
80
+ if (/(permission|privilege|admin|role)/.test(merged)) {
81
+ pushType('permission_grant_super_admin');
82
+ }
83
+ if (/(token|secret|credential|apikey|password)/.test(merged)) {
84
+ pushType('credential_export');
85
+ }
86
+
87
+ if (actionTypes.length === 0) {
88
+ pushType('analysis_only');
89
+ }
90
+ return actionTypes;
91
+ }
92
+
93
+ function actionTemplate(type, index) {
94
+ const base = {
95
+ action_id: `act-${String(index + 1).padStart(3, '0')}`,
96
+ type,
97
+ touches_sensitive_data: false,
98
+ requires_privilege_escalation: false,
99
+ irreversible: false
100
+ };
101
+
102
+ if (['payment_rule_change', 'credential_export'].includes(type)) {
103
+ base.touches_sensitive_data = true;
104
+ }
105
+ if (['permission_grant_super_admin'].includes(type)) {
106
+ base.requires_privilege_escalation = true;
107
+ }
108
+ if (['bulk_delete_without_filter'].includes(type)) {
109
+ base.irreversible = true;
110
+ }
111
+
112
+ return base;
113
+ }
114
+
115
+ function inferRiskLevel(actions, changeIntent, context) {
116
+ const goalText = normalizeText(changeIntent && changeIntent.business_goal).toLowerCase();
117
+ const actionTypes = actions.map(item => item.type);
118
+
119
+ if (
120
+ actionTypes.some(type => [
121
+ 'credential_export',
122
+ 'permission_grant_super_admin',
123
+ 'bulk_delete_without_filter'
124
+ ].includes(type)) ||
125
+ /(delete|privilege|token|secret|credential)/.test(goalText)
126
+ ) {
127
+ return 'high';
128
+ }
129
+
130
+ if (
131
+ actionTypes.some(type => [
132
+ 'workflow_approval_chain_change',
133
+ 'payment_rule_change',
134
+ 'inventory_adjustment_bulk'
135
+ ].includes(type)) ||
136
+ /(approval|workflow|payment|inventory|refund)/.test(goalText) ||
137
+ /(payment|inventory)/.test(normalizeText(context && context.module).toLowerCase())
138
+ ) {
139
+ return 'medium';
140
+ }
141
+
142
+ return 'low';
143
+ }
144
+
145
+ function buildVerificationChecks(actions) {
146
+ const checks = [];
147
+ const pushCheck = (value) => {
148
+ if (!checks.includes(value)) {
149
+ checks.push(value);
150
+ }
151
+ };
152
+
153
+ pushCheck('intent-to-plan consistency review');
154
+
155
+ actions.forEach(action => {
156
+ switch (action.type) {
157
+ case 'workflow_approval_chain_change':
158
+ pushCheck('approval workflow regression smoke');
159
+ pushCheck('approval escalation path validation');
160
+ break;
161
+ case 'update_rule_threshold':
162
+ pushCheck('rule threshold snapshot compare');
163
+ break;
164
+ case 'ui_form_field_adjust':
165
+ pushCheck('ui field rendering smoke');
166
+ break;
167
+ case 'inventory_adjustment_bulk':
168
+ pushCheck('inventory non-negative invariant check');
169
+ break;
170
+ case 'payment_rule_change':
171
+ pushCheck('payment authorization regression smoke');
172
+ break;
173
+ case 'bulk_delete_without_filter':
174
+ pushCheck('destructive action simulation in dry-run');
175
+ break;
176
+ default:
177
+ pushCheck('general change smoke validation');
178
+ break;
179
+ }
180
+ });
181
+
182
+ return checks;
183
+ }
184
+
185
+ function summarizeImpact(actions, context) {
186
+ const actionTypes = actions.map(item => item.type);
187
+ return {
188
+ business: actionTypes.length === 1 && actionTypes[0] === 'analysis_only'
189
+ ? 'analysis only, no direct business mutation proposed'
190
+ : `potential impact on ${normalizeText(context && context.module) || 'business module'} operations`,
191
+ technical: `generated ${actions.length} action(s): ${actionTypes.join(', ')}`,
192
+ data: actions.some(item => item.touches_sensitive_data)
193
+ ? 'sensitive data path involved; masking and approval required'
194
+ : 'no explicit sensitive data write path detected'
195
+ };
196
+ }
197
+
198
+ function buildRollbackPlan(actions, nowIso) {
199
+ const irreversible = actions.some(item => item.irreversible);
200
+ return {
201
+ type: irreversible ? 'backup-restore' : 'config-revert',
202
+ reference: irreversible
203
+ ? `backup-required-${nowIso.slice(0, 10)}`
204
+ : 'previous-config-snapshot',
205
+ note: irreversible
206
+ ? 'irreversible action detected; verified backup is mandatory before apply'
207
+ : 'revert to previous rule/config snapshot'
208
+ };
209
+ }
210
+
211
+ function buildApproval(riskLevel, executionMode, actions) {
212
+ const hasPrivilegeEscalation = actions.some(item => item.requires_privilege_escalation);
213
+ const mustApprove = (
214
+ riskLevel === 'high' ||
215
+ (riskLevel === 'medium' && executionMode === 'apply') ||
216
+ hasPrivilegeEscalation
217
+ );
218
+ return {
219
+ status: mustApprove ? 'pending' : 'not-required',
220
+ dual_approved: false,
221
+ approvers: []
222
+ };
223
+ }
224
+
225
+ function isApproved(changePlan) {
226
+ return (
227
+ changePlan &&
228
+ changePlan.approval &&
229
+ typeof changePlan.approval === 'object' &&
230
+ changePlan.approval.status === 'approved'
231
+ );
232
+ }
233
+
234
+ function buildPlanFromIntent(changeIntent, context, options = {}) {
235
+ const nowIso = options.nowIso || new Date().toISOString();
236
+ const idProvider = typeof options.idProvider === 'function'
237
+ ? options.idProvider
238
+ : () => crypto.randomUUID();
239
+ const fallbackContext = changeIntent && changeIntent.context_ref && typeof changeIntent.context_ref === 'object'
240
+ ? changeIntent.context_ref
241
+ : {};
242
+ const normalizedContext = context && typeof context === 'object' ? context : {};
243
+ const executionMode = ['suggestion', 'apply'].includes(`${options.executionMode || ''}`)
244
+ ? `${options.executionMode}`
245
+ : 'suggestion';
246
+
247
+ const actionTypes = inferActionTypes(changeIntent, normalizedContext);
248
+ const actions = actionTypes.map((type, index) => actionTemplate(type, index));
249
+ const riskLevel = inferRiskLevel(actions, changeIntent, normalizedContext);
250
+ const rollbackPlan = buildRollbackPlan(actions, nowIso);
251
+
252
+ return {
253
+ plan_id: `plan-${idProvider()}`,
254
+ intent_id: normalizeText(changeIntent && changeIntent.intent_id) || `intent-${idProvider()}`,
255
+ risk_level: riskLevel,
256
+ execution_mode: executionMode,
257
+ scope: {
258
+ product: pickContextValue(normalizedContext, fallbackContext, 'product'),
259
+ module: pickContextValue(normalizedContext, fallbackContext, 'module'),
260
+ page: pickContextValue(normalizedContext, fallbackContext, 'page'),
261
+ entity: pickContextValue(normalizedContext, fallbackContext, 'entity'),
262
+ scene_id: pickContextValue(normalizedContext, fallbackContext, 'scene_id')
263
+ },
264
+ actions,
265
+ impact_assessment: summarizeImpact(actions, normalizedContext),
266
+ verification_checks: buildVerificationChecks(actions),
267
+ rollback_plan: rollbackPlan,
268
+ approval: buildApproval(riskLevel, executionMode, actions),
269
+ security: {
270
+ masking_applied: actions.some(item => item.touches_sensitive_data),
271
+ plaintext_secrets_in_payload: false,
272
+ backup_reference: rollbackPlan.type === 'backup-restore'
273
+ ? rollbackPlan.reference
274
+ : undefined
275
+ },
276
+ created_at: nowIso
277
+ };
278
+ }
279
+
280
+ function inferRecordResult(actionResults) {
281
+ const failedCount = actionResults.filter(item => item.status === 'failed').length;
282
+ return failedCount > 0 ? 'failed' : 'success';
283
+ }
284
+
285
+ function summarizeActionDiff(actionResults, runtime) {
286
+ const summaryItems = actionResults.map(item => ({
287
+ action_id: item.action_id || null,
288
+ type: item.type || null,
289
+ mode: item.mode || 'simulate',
290
+ status: item.status || 'failed',
291
+ reason: item.reason || null,
292
+ binding_ref: item.binding_ref || null
293
+ }));
294
+
295
+ return {
296
+ action_total: summaryItems.length,
297
+ success_count: summaryItems.filter(item => item.status === 'success').length,
298
+ failed_count: summaryItems.filter(item => item.status === 'failed').length,
299
+ simulated_count: summaryItems.filter(item => item.mode === 'simulate').length,
300
+ live_count: summaryItems.filter(item => item.mode === 'live').length,
301
+ execution_mode: runtime.liveApply ? 'live' : 'simulate',
302
+ dry_run: runtime.dryRun === true,
303
+ action_results: summaryItems
304
+ };
305
+ }
306
+
307
+ function parseExecutionLedger(text) {
308
+ return `${text || ''}`
309
+ .split(/\r?\n/)
310
+ .map(line => line.trim())
311
+ .filter(Boolean)
312
+ .map((line) => {
313
+ try {
314
+ return JSON.parse(line);
315
+ } catch (error) {
316
+ return null;
317
+ }
318
+ })
319
+ .filter(Boolean);
320
+ }
321
+
322
+ class MoquiInteractiveAdapter {
323
+ constructor(options = {}) {
324
+ this.projectRoot = path.resolve(options.projectRoot || process.cwd());
325
+ this.policyPath = resolvePath(this.projectRoot, options.policyPath || DEFAULT_POLICY);
326
+ this.catalogPath = resolvePath(this.projectRoot, options.catalogPath || DEFAULT_CATALOG);
327
+ this.catalogPathExplicit = Object.prototype.hasOwnProperty.call(options, 'catalogPath');
328
+ this.executionRecordOut = resolvePath(
329
+ this.projectRoot,
330
+ options.executionRecordOut || DEFAULT_EXECUTION_RECORD_OUT
331
+ );
332
+ this.executionLedgerOut = resolvePath(
333
+ this.projectRoot,
334
+ options.executionLedgerOut || DEFAULT_EXECUTION_LEDGER_OUT
335
+ );
336
+ this.defaultLiveApply = options.liveApply === true;
337
+ this.defaultDryRun = options.dryRun !== false;
338
+ this.moquiConfigPath = options.moquiConfigPath || null;
339
+ this.moquiProjectRoot = options.moquiProjectRoot || this.projectRoot;
340
+ this.handlerFactory = typeof options.handlerFactory === 'function'
341
+ ? options.handlerFactory
342
+ : createMoquiAdapterHandler;
343
+ this.moquiHandler = null;
344
+ this.client = options.client || null;
345
+ this.nowProvider = typeof options.nowProvider === 'function'
346
+ ? options.nowProvider
347
+ : () => new Date();
348
+ this.uuidProvider = typeof options.uuidProvider === 'function'
349
+ ? options.uuidProvider
350
+ : () => crypto.randomUUID();
351
+ }
352
+
353
+ capabilities() {
354
+ return {
355
+ adapter_type: ADAPTER_TYPE,
356
+ adapter_version: ADAPTER_VERSION,
357
+ runtime: 'moqui',
358
+ supported_change_types: SUPPORTED_ACTION_TYPES.slice(),
359
+ supported_execution_modes: ['suggestion', 'apply'],
360
+ risk_statement: {
361
+ auto_apply_risk_levels: ['low'],
362
+ approval_required_risk_levels: ['medium', 'high'],
363
+ deny_action_types: [
364
+ 'credential_export',
365
+ 'permission_grant_super_admin',
366
+ 'bulk_delete_without_filter'
367
+ ],
368
+ default_execution_mode: 'suggestion'
369
+ },
370
+ interfaces: ['capabilities', 'plan', 'validate', 'apply', 'rollback'],
371
+ default_runtime_behavior: {
372
+ live_apply: this.defaultLiveApply,
373
+ dry_run: this.defaultDryRun
374
+ }
375
+ };
376
+ }
377
+
378
+ async plan(changeIntent, context = {}, options = {}) {
379
+ if (!changeIntent || typeof changeIntent !== 'object') {
380
+ throw new Error('changeIntent must be a non-null object');
381
+ }
382
+
383
+ const nowIso = this.nowProvider().toISOString();
384
+ return buildPlanFromIntent(changeIntent, context, {
385
+ executionMode: options.executionMode || changeIntent.execution_mode || 'suggestion',
386
+ nowIso,
387
+ idProvider: this.uuidProvider
388
+ });
389
+ }
390
+
391
+ async loadPolicyAndCatalog() {
392
+ if (!(await fs.pathExists(this.policyPath))) {
393
+ throw new Error(`policy not found: ${this.policyPath}`);
394
+ }
395
+
396
+ const policy = await fs.readJson(this.policyPath);
397
+ const catalogFromPolicy = policy &&
398
+ policy.catalog_policy &&
399
+ typeof policy.catalog_policy.catalog_file === 'string'
400
+ ? resolvePath(this.projectRoot, policy.catalog_policy.catalog_file)
401
+ : null;
402
+ const resolvedCatalogPath = this.catalogPathExplicit
403
+ ? this.catalogPath
404
+ : (catalogFromPolicy || this.catalogPath);
405
+
406
+ if (!(await fs.pathExists(resolvedCatalogPath))) {
407
+ throw new Error(`catalog not found: ${resolvedCatalogPath}`);
408
+ }
409
+
410
+ const catalog = await fs.readJson(resolvedCatalogPath);
411
+ return {
412
+ policy,
413
+ catalog,
414
+ policyPath: this.policyPath,
415
+ catalogPath: resolvedCatalogPath
416
+ };
417
+ }
418
+
419
+ async validate(changePlan) {
420
+ if (!changePlan || typeof changePlan !== 'object') {
421
+ throw new Error('changePlan must be a non-null object');
422
+ }
423
+
424
+ const bundle = await this.loadPolicyAndCatalog();
425
+ const evaluation = evaluatePlanGate(changePlan, bundle.policy, bundle.catalog);
426
+ return {
427
+ adapter_type: ADAPTER_TYPE,
428
+ validated_at: this.nowProvider().toISOString(),
429
+ policy_path: path.relative(this.projectRoot, bundle.policyPath) || '.',
430
+ catalog_path: path.relative(this.projectRoot, bundle.catalogPath) || '.',
431
+ ...evaluation
432
+ };
433
+ }
434
+
435
+ getMoquiHandler() {
436
+ if (!this.moquiHandler) {
437
+ this.moquiHandler = this.handlerFactory({
438
+ configPath: this.moquiConfigPath,
439
+ projectRoot: this.moquiProjectRoot,
440
+ client: this.client
441
+ });
442
+ }
443
+ return this.moquiHandler;
444
+ }
445
+
446
+ async executeAction(action, runtime) {
447
+ const result = {
448
+ action_id: action && action.action_id ? action.action_id : null,
449
+ type: action && action.type ? action.type : null,
450
+ mode: runtime.liveApply ? 'live' : 'simulate',
451
+ status: 'success',
452
+ reason: null,
453
+ binding_ref: null
454
+ };
455
+
456
+ if (!runtime.liveApply || runtime.dryRun) {
457
+ return result;
458
+ }
459
+
460
+ const bindingRef = normalizeText(
461
+ action && (action.binding_ref || action.moqui_binding_ref || action.bindingRef)
462
+ );
463
+
464
+ result.binding_ref = bindingRef || null;
465
+
466
+ if (!bindingRef) {
467
+ result.status = 'failed';
468
+ result.reason = 'live apply requires action.binding_ref';
469
+ return result;
470
+ }
471
+
472
+ const handler = this.getMoquiHandler();
473
+ const payload = action && typeof action.payload === 'object' ? action.payload : {};
474
+ const response = await handler.execute({ binding_ref: bindingRef }, payload);
475
+
476
+ if (!response || response.status !== 'success') {
477
+ const errorMessage = response && response.error && response.error.message
478
+ ? response.error.message
479
+ : 'moqui execution failed';
480
+ result.status = 'failed';
481
+ result.reason = errorMessage;
482
+ }
483
+
484
+ return result;
485
+ }
486
+
487
+ buildExecutionRecord(changePlan, validation, actionResults, overrides = {}) {
488
+ const executionId = normalizeText(overrides.executionId) || `exec-${this.uuidProvider()}`;
489
+ const auditTraceId = normalizeText(overrides.auditTraceId) || `audit-${this.uuidProvider()}`;
490
+ const nowIso = this.nowProvider().toISOString();
491
+ const result = normalizeText(overrides.result) || inferRecordResult(actionResults);
492
+ const rollbackRef = result === 'success'
493
+ ? `rollback-${executionId}`
494
+ : (normalizeText(overrides.rollbackRef) || null);
495
+
496
+ const record = {
497
+ execution_id: executionId,
498
+ plan_id: normalizeText(changePlan && changePlan.plan_id) || 'unknown-plan',
499
+ adapter_type: ADAPTER_TYPE,
500
+ policy_decision: validation && validation.decision ? validation.decision : 'deny',
501
+ approval_snapshot: changePlan && changePlan.approval ? changePlan.approval : {},
502
+ diff_summary: summarizeActionDiff(actionResults, overrides.runtime || {}),
503
+ result,
504
+ rollback_ref: rollbackRef,
505
+ audit_trace_id: auditTraceId,
506
+ executed_at: nowIso
507
+ };
508
+
509
+ if (overrides.validationSnapshot) {
510
+ record.validation_snapshot = overrides.validationSnapshot;
511
+ }
512
+
513
+ return record;
514
+ }
515
+
516
+ async persistExecutionRecord(record) {
517
+ await fs.ensureDir(path.dirname(this.executionRecordOut));
518
+ await fs.writeJson(this.executionRecordOut, record, { spaces: 2 });
519
+
520
+ await fs.ensureDir(path.dirname(this.executionLedgerOut));
521
+ await fs.appendFile(this.executionLedgerOut, `${JSON.stringify(record)}\n`, 'utf8');
522
+ }
523
+
524
+ async apply(changePlan, options = {}) {
525
+ if (!changePlan || typeof changePlan !== 'object') {
526
+ throw new Error('changePlan must be a non-null object');
527
+ }
528
+
529
+ const runtime = {
530
+ liveApply: options.liveApply === true ? true : this.defaultLiveApply,
531
+ dryRun: options.dryRun === false ? false : this.defaultDryRun
532
+ };
533
+
534
+ const validation = await this.validate(changePlan);
535
+
536
+ if (validation.decision === 'deny') {
537
+ const blocked = this.buildExecutionRecord(
538
+ changePlan,
539
+ validation,
540
+ [],
541
+ {
542
+ result: 'failed',
543
+ rollbackRef: null,
544
+ validationSnapshot: validation,
545
+ runtime
546
+ }
547
+ );
548
+ blocked.diff_summary.reason = 'blocked by policy gate (deny)';
549
+ await this.persistExecutionRecord(blocked);
550
+ return { blocked: true, reason: blocked.diff_summary.reason, record: blocked, validation };
551
+ }
552
+
553
+ if (validation.decision === 'review-required' && !isApproved(changePlan)) {
554
+ const blocked = this.buildExecutionRecord(
555
+ changePlan,
556
+ validation,
557
+ [],
558
+ {
559
+ result: 'skipped',
560
+ rollbackRef: null,
561
+ validationSnapshot: validation,
562
+ runtime
563
+ }
564
+ );
565
+ blocked.diff_summary.reason = 'approval required before apply';
566
+ await this.persistExecutionRecord(blocked);
567
+ return { blocked: true, reason: blocked.diff_summary.reason, record: blocked, validation };
568
+ }
569
+
570
+ if (
571
+ changePlan.execution_mode === 'suggestion' &&
572
+ options.allowSuggestionApply !== true
573
+ ) {
574
+ const skipped = this.buildExecutionRecord(
575
+ changePlan,
576
+ validation,
577
+ [],
578
+ {
579
+ result: 'skipped',
580
+ rollbackRef: null,
581
+ validationSnapshot: validation,
582
+ runtime
583
+ }
584
+ );
585
+ skipped.diff_summary.reason = 'plan is in suggestion mode; set allowSuggestionApply to execute';
586
+ await this.persistExecutionRecord(skipped);
587
+ return { blocked: true, reason: skipped.diff_summary.reason, record: skipped, validation };
588
+ }
589
+
590
+ const actions = Array.isArray(changePlan.actions)
591
+ ? changePlan.actions.filter(item => item && typeof item === 'object')
592
+ : [];
593
+ const actionResults = [];
594
+
595
+ for (const action of actions) {
596
+ const actionResult = await this.executeAction(action, runtime);
597
+ actionResults.push(actionResult);
598
+ }
599
+
600
+ const record = this.buildExecutionRecord(changePlan, validation, actionResults, {
601
+ runtime,
602
+ validationSnapshot: validation
603
+ });
604
+ await this.persistExecutionRecord(record);
605
+
606
+ return {
607
+ blocked: false,
608
+ reason: null,
609
+ record,
610
+ validation,
611
+ action_results: actionResults
612
+ };
613
+ }
614
+
615
+ async applyLowRisk(changePlan, options = {}) {
616
+ if (!changePlan || typeof changePlan !== 'object') {
617
+ throw new Error('changePlan must be a non-null object');
618
+ }
619
+
620
+ const runtime = {
621
+ liveApply: options.liveApply === true ? true : this.defaultLiveApply,
622
+ dryRun: options.dryRun === false ? false : this.defaultDryRun
623
+ };
624
+
625
+ const validation = await this.validate(changePlan);
626
+ const riskLevel = normalizeText(changePlan.risk_level).toLowerCase();
627
+
628
+ if (validation.decision !== 'allow' || riskLevel !== 'low') {
629
+ const blocked = this.buildExecutionRecord(
630
+ changePlan,
631
+ validation,
632
+ [],
633
+ {
634
+ result: 'skipped',
635
+ rollbackRef: null,
636
+ validationSnapshot: validation,
637
+ runtime
638
+ }
639
+ );
640
+ blocked.diff_summary.reason = 'low-risk apply accepts only risk_level=low and gate decision=allow';
641
+ await this.persistExecutionRecord(blocked);
642
+ return {
643
+ blocked: true,
644
+ reason: blocked.diff_summary.reason,
645
+ record: blocked,
646
+ validation
647
+ };
648
+ }
649
+
650
+ return this.apply(changePlan, {
651
+ ...options,
652
+ liveApply: runtime.liveApply,
653
+ dryRun: runtime.dryRun
654
+ });
655
+ }
656
+
657
+ async readLedger() {
658
+ if (!(await fs.pathExists(this.executionLedgerOut))) {
659
+ return [];
660
+ }
661
+ const content = await fs.readFile(this.executionLedgerOut, 'utf8');
662
+ return parseExecutionLedger(content);
663
+ }
664
+
665
+ async rollback(executionId) {
666
+ const targetExecutionId = normalizeText(executionId);
667
+ if (!targetExecutionId) {
668
+ throw new Error('executionId is required');
669
+ }
670
+
671
+ const ledger = await this.readLedger();
672
+ const target = ledger.find(item => item && item.execution_id === targetExecutionId);
673
+
674
+ if (!target) {
675
+ const missingRecord = {
676
+ execution_id: `exec-${this.uuidProvider()}`,
677
+ plan_id: 'unknown-plan',
678
+ adapter_type: ADAPTER_TYPE,
679
+ policy_decision: 'deny',
680
+ approval_snapshot: {},
681
+ diff_summary: {
682
+ reason: `execution_id not found: ${targetExecutionId}`
683
+ },
684
+ result: 'failed',
685
+ rollback_ref: targetExecutionId,
686
+ audit_trace_id: `audit-${this.uuidProvider()}`,
687
+ executed_at: this.nowProvider().toISOString()
688
+ };
689
+ await this.persistExecutionRecord(missingRecord);
690
+ return {
691
+ found: false,
692
+ record: missingRecord
693
+ };
694
+ }
695
+
696
+ const rollbackRecord = {
697
+ execution_id: `exec-${this.uuidProvider()}`,
698
+ plan_id: target.plan_id || 'unknown-plan',
699
+ adapter_type: ADAPTER_TYPE,
700
+ policy_decision: target.policy_decision || 'review-required',
701
+ approval_snapshot: target.approval_snapshot || {},
702
+ diff_summary: {
703
+ rollback_of_execution_id: targetExecutionId,
704
+ rollback_scope: target.diff_summary || {},
705
+ note: 'rollback recorded as controlled simulation'
706
+ },
707
+ result: 'rolled-back',
708
+ rollback_ref: targetExecutionId,
709
+ audit_trace_id: `audit-${this.uuidProvider()}`,
710
+ executed_at: this.nowProvider().toISOString()
711
+ };
712
+ rollbackRecord.validation_snapshot = target.validation_snapshot || null;
713
+
714
+ await this.persistExecutionRecord(rollbackRecord);
715
+ return {
716
+ found: true,
717
+ record: rollbackRecord,
718
+ source: target
719
+ };
720
+ }
721
+ }
722
+
723
+ module.exports = {
724
+ ADAPTER_TYPE,
725
+ ADAPTER_VERSION,
726
+ DEFAULT_EXECUTION_RECORD_OUT,
727
+ DEFAULT_EXECUTION_LEDGER_OUT,
728
+ SUPPORTED_ACTION_TYPES,
729
+ buildPlanFromIntent,
730
+ parseExecutionLedger,
731
+ MoquiInteractiveAdapter
732
+ };