@crediolabs/policy-builder-cli 0.1.11 → 0.1.12

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.
@@ -6,7 +6,7 @@
6
6
  // [--xdr <b64>] [--json] [--quiet] [--out <path>]
7
7
  // policy-builder synthesize --mandate <path.json>
8
8
  // [--oz-config <path.json>] [--confidence <0..1>]
9
- // [--json] [--quiet] [--out <path>]
9
+ // [--explain] [--json] [--quiet] [--out <path>]
10
10
  // policy-builder synthesize --recorded-tx <path.json> --network <mainnet|testnet>
11
11
  // [--responses <path.json>]
12
12
  // [--oz-config <path.json>] [--confidence <0..1>]
@@ -15,7 +15,12 @@
15
15
  // [--window-seconds <n>] [--valid-until <ledger>]
16
16
  // [--limit-amount <i128str>] [--invocation-limit <n>]
17
17
  // [--recipient <C...|G...>]...
18
- // [--json] [--quiet] [--out <path>]
18
+ // [--explain] [--json] [--quiet] [--out <path>]
19
+ //
20
+ // --explain (Phase 1) makes the synthesised policy human-readable: the
21
+ // output gains `review` (the deterministic review-card summary) and
22
+ // `predicateTree` (the in-memory interpreter predicate as JSON). Without
23
+ // --explain the output is byte-identical to today.
19
24
  //
20
25
  // The router is hand-rolled (no commander / yargs dep). It splits argv into
21
26
  // `command + subcommand-flags + global-flags` and dispatches to the matching
@@ -14,7 +14,13 @@
14
14
  // Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
15
15
  // the interpreter opt-in and are rejected without --smart-account; tighten-only
16
16
  // bounds are validated by the core.
17
- import { isStellarAddress } from '@crediolabs/policy-synth';
17
+ //
18
+ // --explain (Phase 1) makes the synthesised policy human-readable. The
19
+ // orchestrator attaches the in-memory predicate tree + a SimulationResult
20
+ // to the success envelope; the CLI builds the deterministic review card
21
+ // from those inputs and emits it alongside the policy. Without --explain
22
+ // the output is byte-identical to today.
23
+ import { buildReviewCardSummary, isStellarAddress, } from '@crediolabs/policy-synth';
18
24
  import { runSynthesizePolicy } from '@crediolabs/policy-synth/run';
19
25
  import { CliError, formatToolResponse, parsePairs, readJsonFile } from "../output.js";
20
26
  // Positive-int flags and i128 amount strings share the same wire shape: a
@@ -33,6 +39,12 @@ export async function runSynthesizeCommand(argv, flags) {
33
39
  retryable: false,
34
40
  });
35
41
  }
42
+ // --explain is a STANDALONE flag (no value). Detect presence via
43
+ // `!== undefined` so `--explain` (no value) and a missing flag are
44
+ // distinguishable. The core defaults to ADDITIVE: with --explain the
45
+ // output gains `review` + `predicateTree` and remains unchanged
46
+ // otherwise.
47
+ const explain = pairs.explain !== undefined;
36
48
  if (hasMandate) {
37
49
  const mandate = readJsonFile(pairs.mandate);
38
50
  const args = { source: 'mandate', mandate };
@@ -42,7 +54,11 @@ export async function runSynthesizeCommand(argv, flags) {
42
54
  if (pairs.confidence !== undefined) {
43
55
  args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
44
56
  }
57
+ if (explain)
58
+ args.explain = true;
45
59
  const res = await runSynthesizePolicy(args);
60
+ if (explain)
61
+ emitExplainBlock(res, flags);
46
62
  return formatToolResponse(res, flags, 'synthesize(mandate)');
47
63
  }
48
64
  // hasRecorded
@@ -200,9 +216,60 @@ export async function runSynthesizeCommand(argv, flags) {
200
216
  }
201
217
  args.interpreter = interpreter;
202
218
  }
219
+ if (explain)
220
+ args.explain = true;
203
221
  const res = await runSynthesizePolicy(args);
222
+ // formatToolResponse runs FIRST so the "ok" line + --out / --json writes
223
+ // happen with the additive fields already attached. emitExplainBlock
224
+ // mutates `res.data` in place to inject `review` + `predicateTree`, so
225
+ // the --out artefact and --json stdout both carry the same shape.
226
+ if (explain)
227
+ emitExplainBlock(res, flags);
204
228
  return formatToolResponse(res, flags, 'synthesize(recording)');
205
229
  }
230
+ /** Augment the tool response envelope with the --explain fields and
231
+ * (in non-JSON mode) print the review card readably. The CLI is the
232
+ * single seam that places `review` + `predicateTree` on the wire
233
+ * envelope; the core stays downstream of `formatToolResponse` so the
234
+ * byte-identical no-flag path is preserved. The envelope mutation is
235
+ * done BEFORE `formatToolResponse` so the on-disk --out artefact and
236
+ * the --json stdout both carry the same fields. */
237
+ function emitExplainBlock(res, flags) {
238
+ if (!res.ok || !res.explain)
239
+ return;
240
+ const review = buildReviewCardSummary(
241
+ // The orchestrator's `predicateTree` is the exact in-memory AST
242
+ // (canonical JSON shape). The builder's input is typed as
243
+ // `PredicateNode | null`; the orchestrator's `null` is the truthful
244
+ // OZ-only / mandate value, so a null here is honest.
245
+ (res.explain.predicateTree ?? null), res.data?.policyRefs ?? [], res.data?.contextRule ?? {
246
+ contextRuleType: { kind: 'default' },
247
+ name: 'unknown',
248
+ validUntilLedger: null,
249
+ signers: [],
250
+ policies: [],
251
+ }, res.explain.simulation);
252
+ res.data.review =
253
+ review;
254
+ res.data.predicateTree = res.explain.predicateTree;
255
+ // In human mode (no --json), print the review card readably: the rule name,
256
+ // the expiry, then one line per constraint. Plain text; no colour, no box
257
+ // drawing. The "ok" line is printed by formatToolResponse; this block prints
258
+ // ONLY the review-card lines.
259
+ //
260
+ // `review.plainEnglish` is deliberately not printed here: it is the same
261
+ // constraint list joined into one sentence, so emitting both would state
262
+ // every constraint twice. It stays on the JSON envelope for callers that
263
+ // want a single-string summary.
264
+ if (!flags.json && !flags.quiet) {
265
+ process.stdout.write(`Review card: ${review.ruleName}\n`);
266
+ process.stdout.write(` ${review.expiry}\n`);
267
+ process.stdout.write(' Constraints:\n');
268
+ for (const line of review.constraints) {
269
+ process.stdout.write(` - ${line}\n`);
270
+ }
271
+ }
272
+ }
206
273
  /** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
207
274
  * CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
208
275
  * on `ozConfig` catches shape mismatches downstream. */
@@ -15,6 +15,12 @@
15
15
  // Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
16
16
  // the interpreter opt-in and are rejected without --smart-account; tighten-only
17
17
  // bounds are validated by the core.
18
+ //
19
+ // --explain (Phase 1) makes the synthesised policy human-readable. The
20
+ // orchestrator attaches the in-memory predicate tree + a SimulationResult
21
+ // to the success envelope; the CLI builds the deterministic review card
22
+ // from those inputs and emits it alongside the policy. Without --explain
23
+ // the output is byte-identical to today.
18
24
  Object.defineProperty(exports, "__esModule", { value: true });
19
25
  exports.runSynthesizeCommand = runSynthesizeCommand;
20
26
  const policy_synth_1 = require("@crediolabs/policy-synth");
@@ -36,6 +42,12 @@ async function runSynthesizeCommand(argv, flags) {
36
42
  retryable: false,
37
43
  });
38
44
  }
45
+ // --explain is a STANDALONE flag (no value). Detect presence via
46
+ // `!== undefined` so `--explain` (no value) and a missing flag are
47
+ // distinguishable. The core defaults to ADDITIVE: with --explain the
48
+ // output gains `review` + `predicateTree` and remains unchanged
49
+ // otherwise.
50
+ const explain = pairs.explain !== undefined;
39
51
  if (hasMandate) {
40
52
  const mandate = (0, output_ts_1.readJsonFile)(pairs.mandate);
41
53
  const args = { source: 'mandate', mandate };
@@ -45,7 +57,11 @@ async function runSynthesizeCommand(argv, flags) {
45
57
  if (pairs.confidence !== undefined) {
46
58
  args.confidenceOverride = { threshold: parseConfidence(pairs.confidence) };
47
59
  }
60
+ if (explain)
61
+ args.explain = true;
48
62
  const res = await (0, run_1.runSynthesizePolicy)(args);
63
+ if (explain)
64
+ emitExplainBlock(res, flags);
49
65
  return (0, output_ts_1.formatToolResponse)(res, flags, 'synthesize(mandate)');
50
66
  }
51
67
  // hasRecorded
@@ -203,9 +219,60 @@ async function runSynthesizeCommand(argv, flags) {
203
219
  }
204
220
  args.interpreter = interpreter;
205
221
  }
222
+ if (explain)
223
+ args.explain = true;
206
224
  const res = await (0, run_1.runSynthesizePolicy)(args);
225
+ // formatToolResponse runs FIRST so the "ok" line + --out / --json writes
226
+ // happen with the additive fields already attached. emitExplainBlock
227
+ // mutates `res.data` in place to inject `review` + `predicateTree`, so
228
+ // the --out artefact and --json stdout both carry the same shape.
229
+ if (explain)
230
+ emitExplainBlock(res, flags);
207
231
  return (0, output_ts_1.formatToolResponse)(res, flags, 'synthesize(recording)');
208
232
  }
233
+ /** Augment the tool response envelope with the --explain fields and
234
+ * (in non-JSON mode) print the review card readably. The CLI is the
235
+ * single seam that places `review` + `predicateTree` on the wire
236
+ * envelope; the core stays downstream of `formatToolResponse` so the
237
+ * byte-identical no-flag path is preserved. The envelope mutation is
238
+ * done BEFORE `formatToolResponse` so the on-disk --out artefact and
239
+ * the --json stdout both carry the same fields. */
240
+ function emitExplainBlock(res, flags) {
241
+ if (!res.ok || !res.explain)
242
+ return;
243
+ const review = (0, policy_synth_1.buildReviewCardSummary)(
244
+ // The orchestrator's `predicateTree` is the exact in-memory AST
245
+ // (canonical JSON shape). The builder's input is typed as
246
+ // `PredicateNode | null`; the orchestrator's `null` is the truthful
247
+ // OZ-only / mandate value, so a null here is honest.
248
+ (res.explain.predicateTree ?? null), res.data?.policyRefs ?? [], res.data?.contextRule ?? {
249
+ contextRuleType: { kind: 'default' },
250
+ name: 'unknown',
251
+ validUntilLedger: null,
252
+ signers: [],
253
+ policies: [],
254
+ }, res.explain.simulation);
255
+ res.data.review =
256
+ review;
257
+ res.data.predicateTree = res.explain.predicateTree;
258
+ // In human mode (no --json), print the review card readably: the rule name,
259
+ // the expiry, then one line per constraint. Plain text; no colour, no box
260
+ // drawing. The "ok" line is printed by formatToolResponse; this block prints
261
+ // ONLY the review-card lines.
262
+ //
263
+ // `review.plainEnglish` is deliberately not printed here: it is the same
264
+ // constraint list joined into one sentence, so emitting both would state
265
+ // every constraint twice. It stays on the JSON envelope for callers that
266
+ // want a single-string summary.
267
+ if (!flags.json && !flags.quiet) {
268
+ process.stdout.write(`Review card: ${review.ruleName}\n`);
269
+ process.stdout.write(` ${review.expiry}\n`);
270
+ process.stdout.write(' Constraints:\n');
271
+ for (const line of review.constraints) {
272
+ process.stdout.write(` - ${line}\n`);
273
+ }
274
+ }
275
+ }
209
276
  /** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
210
277
  * CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
211
278
  * on `ozConfig` catches shape mismatches downstream. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-builder-cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "license": "MIT",
5
5
  "description": "CLI wrapper around the OZ policy-synth core (record + synthesize) for solo-dev and CI workflows.",
6
6
  "type": "module",
@@ -63,7 +63,7 @@
63
63
  "prepack": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@crediolabs/policy-synth": "0.1.9",
66
+ "@crediolabs/policy-synth": "0.1.10",
67
67
  "zod": "3.25.76"
68
68
  },
69
69
  "devDependencies": {
@@ -14,11 +14,33 @@
14
14
  // Oracle params (--oracle-max-staleness, --oracle-max-deviation) are part of
15
15
  // the interpreter opt-in and are rejected without --smart-account; tighten-only
16
16
  // bounds are validated by the core.
17
+ //
18
+ // --explain (Phase 1) makes the synthesised policy human-readable. The
19
+ // orchestrator attaches the in-memory predicate tree + a SimulationResult
20
+ // to the success envelope; the CLI builds the deterministic review card
21
+ // from those inputs and emits it alongside the policy. Without --explain
22
+ // the output is byte-identical to today.
17
23
 
18
- import { isStellarAddress, type ProposedPolicy } from '@crediolabs/policy-synth'
24
+ import {
25
+ buildReviewCardSummary,
26
+ isStellarAddress,
27
+ type ProposedPolicy,
28
+ type ReviewCardSummary,
29
+ } from '@crediolabs/policy-synth'
19
30
  import { runSynthesizePolicy } from '@crediolabs/policy-synth/run'
20
31
  import { CliError, type CliFlags, formatToolResponse, parsePairs, readJsonFile } from '../output.ts'
21
32
 
33
+ /** Local mirror of the synth core's `SimulationResult` shape. The verify
34
+ * module is internal to the package, so the CLI keeps this minimal copy
35
+ * instead of importing the deep path - the contract is small enough that
36
+ * a structural type is cheaper than a new package export. */
37
+ type SimulationResult = {
38
+ permit: { tx: 'permit' } | { tx: 'deny'; reason: string }
39
+ evaluatedCases: Array<{ dimension: string; outcome: 'permit' | 'deny'; reason: string }>
40
+ backend: 'interpreter-v1' | 'ts-model'
41
+ simulatorVersion: string
42
+ }
43
+
22
44
  // Positive-int flags and i128 amount strings share the same wire shape: a
23
45
  // base-10 unsigned decimal, no sign. The i128 stays a string at the boundary
24
46
  // because it is wider than Number.MAX_SAFE_INTEGER.
@@ -40,6 +62,13 @@ export async function runSynthesizeCommand(
40
62
  })
41
63
  }
42
64
 
65
+ // --explain is a STANDALONE flag (no value). Detect presence via
66
+ // `!== undefined` so `--explain` (no value) and a missing flag are
67
+ // distinguishable. The core defaults to ADDITIVE: with --explain the
68
+ // output gains `review` + `predicateTree` and remains unchanged
69
+ // otherwise.
70
+ const explain = pairs.explain !== undefined
71
+
43
72
  if (hasMandate) {
44
73
  const mandate = readJsonFile(pairs.mandate as string) as Record<string, unknown>
45
74
  const args: Record<string, unknown> = { source: 'mandate', mandate }
@@ -49,7 +78,9 @@ export async function runSynthesizeCommand(
49
78
  if (pairs.confidence !== undefined) {
50
79
  args.confidenceOverride = { threshold: parseConfidence(pairs.confidence as string) }
51
80
  }
81
+ if (explain) args.explain = true
52
82
  const res = await runSynthesizePolicy(args)
83
+ if (explain) emitExplainBlock(res, flags)
53
84
  return formatToolResponse(res, flags, 'synthesize(mandate)')
54
85
  }
55
86
 
@@ -230,10 +261,78 @@ export async function runSynthesizeCommand(
230
261
  }
231
262
  args.interpreter = interpreter
232
263
  }
264
+ if (explain) args.explain = true
233
265
  const res = await runSynthesizePolicy(args)
266
+ // formatToolResponse runs FIRST so the "ok" line + --out / --json writes
267
+ // happen with the additive fields already attached. emitExplainBlock
268
+ // mutates `res.data` in place to inject `review` + `predicateTree`, so
269
+ // the --out artefact and --json stdout both carry the same shape.
270
+ if (explain) emitExplainBlock(res, flags)
234
271
  return formatToolResponse(res, flags, 'synthesize(recording)')
235
272
  }
236
273
 
274
+ /** Augment the tool response envelope with the --explain fields and
275
+ * (in non-JSON mode) print the review card readably. The CLI is the
276
+ * single seam that places `review` + `predicateTree` on the wire
277
+ * envelope; the core stays downstream of `formatToolResponse` so the
278
+ * byte-identical no-flag path is preserved. The envelope mutation is
279
+ * done BEFORE `formatToolResponse` so the on-disk --out artefact and
280
+ * the --json stdout both carry the same fields. */
281
+ function emitExplainBlock(
282
+ res: {
283
+ ok: boolean
284
+ data?: ProposedPolicy
285
+ explain?: {
286
+ predicateTree: unknown
287
+ simulation: SimulationResult
288
+ }
289
+ },
290
+ flags: CliFlags
291
+ ): void {
292
+ if (!res.ok || !res.explain) return
293
+ const review = buildReviewCardSummary(
294
+ // The orchestrator's `predicateTree` is the exact in-memory AST
295
+ // (canonical JSON shape). The builder's input is typed as
296
+ // `PredicateNode | null`; the orchestrator's `null` is the truthful
297
+ // OZ-only / mandate value, so a null here is honest.
298
+ (res.explain.predicateTree ?? null) as never,
299
+ res.data?.policyRefs ?? [],
300
+ res.data?.contextRule ?? {
301
+ contextRuleType: { kind: 'default' as const },
302
+ name: 'unknown',
303
+ validUntilLedger: null,
304
+ signers: [],
305
+ policies: [],
306
+ },
307
+ res.explain.simulation
308
+ )
309
+ // Attach the two additive fields to the on-wire envelope. formatToolResponse
310
+ // reads from `res` and writes the JSON; we mutate the same object so the
311
+ // --json path + --out path see the same shape.
312
+ ;(res.data as ProposedPolicy & { review: ReviewCardSummary; predicateTree: unknown }).review =
313
+ review
314
+ ;(
315
+ res.data as ProposedPolicy & { review: ReviewCardSummary; predicateTree: unknown }
316
+ ).predicateTree = res.explain.predicateTree
317
+ // In human mode (no --json), print the review card readably: the rule name,
318
+ // the expiry, then one line per constraint. Plain text; no colour, no box
319
+ // drawing. The "ok" line is printed by formatToolResponse; this block prints
320
+ // ONLY the review-card lines.
321
+ //
322
+ // `review.plainEnglish` is deliberately not printed here: it is the same
323
+ // constraint list joined into one sentence, so emitting both would state
324
+ // every constraint twice. It stays on the JSON envelope for callers that
325
+ // want a single-string summary.
326
+ if (!flags.json && !flags.quiet) {
327
+ process.stdout.write(`Review card: ${review.ruleName}\n`)
328
+ process.stdout.write(` ${review.expiry}\n`)
329
+ process.stdout.write(' Constraints:\n')
330
+ for (const line of review.constraints) {
331
+ process.stdout.write(` - ${line}\n`)
332
+ }
333
+ }
334
+ }
335
+
237
336
  /** Read and validate an OzAdapterConfig JSON file. Throws CLI_FILE_NOT_FOUND /
238
337
  * CLI_INVALID_JSON for filesystem / parse failures; the core's strict schema
239
338
  * on `ozConfig` catches shape mismatches downstream. */