@getripple/cli 1.0.12 → 1.0.14-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,10 +24,12 @@ var __importStar = (this && this.__importStar) || function (mod) {
24
24
  return result;
25
25
  };
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
+ const os = __importStar(require("os"));
27
28
  const fs = __importStar(require("fs"));
28
29
  const path = __importStar(require("path"));
29
30
  const child_process_1 = require("child_process");
30
31
  const core_1 = require("@getripple/core");
32
+ const crypto = __importStar(require("crypto"));
31
33
  const CONTROL_MODES = ["brainstorm", "function", "file", "task", "pr"];
32
34
  function usage() {
33
35
  return [
@@ -65,6 +67,7 @@ function usage() {
65
67
  " ripple agent",
66
68
  " ripple agent setup [--print] [--force]",
67
69
  " ripple hook install [--print] [--force]",
70
+ " ripple demo [--publish] [--json]",
68
71
  "",
69
72
  "Options:",
70
73
  " --json, -j Print machine-readable JSON",
@@ -88,6 +91,7 @@ function usage() {
88
91
  " --github-annotations Emit GitHub Actions annotations for CI findings",
89
92
  " --print Print generated setup content instead of writing files",
90
93
  " --force Overwrite existing generated setup files",
94
+ " --publish ripple demo only: send the run to Ripple Cloud and print a PUBLIC share link (needs RIPPLE_API_KEY)",
91
95
  "",
92
96
  "Examples:",
93
97
  " ripple init",
@@ -319,6 +323,7 @@ function parseCliArgs(argv) {
319
323
  githubAnnotations: false,
320
324
  force: false,
321
325
  print: false,
326
+ publish: false,
322
327
  };
323
328
  for (let i = 0; i < argv.length; i++) {
324
329
  const token = argv[i];
@@ -362,6 +367,10 @@ function parseCliArgs(argv) {
362
367
  options.print = true;
363
368
  continue;
364
369
  }
370
+ if (token === "--publish") {
371
+ options.publish = true;
372
+ continue;
373
+ }
365
374
  if (token === "--last") {
366
375
  const value = argv[i + 1];
367
376
  if (!value || value.startsWith("-")) {
@@ -639,13 +648,15 @@ function rippleDirectRunnerHookLines() {
639
648
  }
640
649
  const GITHUB_ACTIONS_WORKFLOW_PATH = core_1.RIPPLE_CI_WORKFLOW_PATH;
641
650
  function githubActionsWorkflow() {
651
+ // This function generates the definitive, user-friendly, public-package-based workflow.
652
+ // It correctly uses `npx` with `@latest` to ensure the CI runner always gets
653
+ // the most recently published, working version of the CLI.
642
654
  return [
643
655
  "name: Ripple Enterprise Gate",
644
656
  "",
645
657
  "on:",
646
658
  " pull_request:",
647
- " push:",
648
- " branches: [main, master]",
659
+ " types: [opened, synchronize, reopened]",
649
660
  "",
650
661
  "permissions:",
651
662
  " contents: read",
@@ -653,23 +664,27 @@ function githubActionsWorkflow() {
653
664
  " checks: write",
654
665
  "",
655
666
  "jobs:",
656
- " ripple:",
667
+ " ripple-gate:",
657
668
  " name: Ripple authorization gate",
658
669
  " runs-on: ubuntu-latest",
659
670
  " steps:",
660
- " - name: Checkout",
671
+ " - name: Checkout PR Code",
661
672
  " uses: actions/checkout@v4",
662
673
  " with:",
663
674
  " fetch-depth: 0",
664
- " - name: Setup Node",
675
+ " ",
676
+ " - name: Setup Node.js",
665
677
  " uses: actions/setup-node@v4",
666
678
  " with:",
667
- " node-version: 20",
679
+ " node-version: '22.x' # Use a current LTS version",
680
+ "",
668
681
  " - name: Ripple CI gate",
669
- ` run: npx -y ${rippleCliPackageSpec()} ci --base origin/\\\${{ github.base_ref || 'main' }} --github-annotations --sha \\\${{ github.event.pull_request.head.sha || github.sha }}`,
670
682
  " env:",
671
683
  " RIPPLE_API_KEY: ${{ secrets.RIPPLE_API_KEY }}",
672
684
  " RIPPLE_CLOUD_URL: ${{ secrets.RIPPLE_CLOUD_URL }}",
685
+ " run: |",
686
+ " # Use npx to fetch the same published CLI version that generated this workflow.",
687
+ ` npx -y ${rippleCliPackageSpec()} ci --base origin/\${{ github.base_ref }} --github-annotations --sha \${{ github.event.pull_request.head.sha || github.sha }}`,
673
688
  "",
674
689
  ].join("\n");
675
690
  }
@@ -3411,8 +3426,6 @@ async function auditCommand(options) {
3411
3426
  }
3412
3427
  applyStrictExit(options.strict && strictAuditShouldFail(audit));
3413
3428
  }
3414
- // in: ripple-main/packages/cli/src/index.ts
3415
- // REPLACE THE ENTIRE OLD `gateCommand` FUNCTION WITH THIS:
3416
3429
  async function gateCommand(options) {
3417
3430
  if (selectedChangeModeCount(options) > 1) {
3418
3431
  throw new Error("Choose one gate/audit mode: --staged, --worktree, or --changed --base <ref>");
@@ -3456,60 +3469,13 @@ async function gateCommand(options) {
3456
3469
  else {
3457
3470
  printGateSummary(gate);
3458
3471
  }
3459
- // Consume intent on pass to prevent "ghost intents" if commit is aborted.
3460
- if (gate.canContinue && intentRef === "latest" && mode === "staged") {
3461
- try {
3462
- const consumedIntentCachePath = path.join(workspaceRoot, ".ripple", ".cache", "consumed-intent.json");
3463
- if (fs.existsSync(consumedIntentCachePath)) {
3464
- fs.unlinkSync(consumedIntentCachePath);
3465
- }
3466
- fs.renameSync(intentPath, consumedIntentCachePath);
3467
- console.log("\n[Ripple] Intent validated and consumed by gate. Ready for commit.");
3468
- }
3469
- catch (err) {
3470
- const errorDetail = err instanceof Error ? err.message : String(err);
3471
- console.warn(`[Ripple] CRITICAL WARNING: Could not consume intent file. To prevent a future 'ghost intent' block, manually delete '${intentPath}' after your commit. Error: ${errorDetail}`);
3472
- }
3473
- }
3474
- // Sync a redacted, privacy-safe audit receipt to the cloud.
3475
- if (process.env.RIPPLE_API_KEY) {
3476
- const cloudDecision = gate.canContinue ? "continue" :
3477
- gate.needsHuman ? "human-review" : "blocked";
3478
- const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
3479
- const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
3480
- const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
3481
- const redactedPayload = {
3482
- decision: gate.decision,
3483
- status: gate.status,
3484
- risk: {
3485
- level: gate.risk.level,
3486
- score: gate.risk.score,
3487
- summary: gate.risk.summary,
3488
- },
3489
- auditStatus: audit.status,
3490
- approvalStatus: audit.approvalStatus.status,
3491
- intent: audit.intent,
3492
- mode: audit.mode,
3493
- baseRef: audit.baseRef,
3494
- blockingReasons: audit.blockingReasons,
3495
- };
3496
- const cloudResult = await (0, core_1.syncAuditToCloud)({
3497
- intentId: audit.intent.id,
3498
- decision: cloudDecision,
3499
- commitSha,
3500
- branch,
3501
- actor,
3502
- source: "cli",
3503
- payload: redactedPayload,
3504
- });
3505
- if (cloudResult.sent) {
3506
- const icon = cloudDecision === "continue" ? "✓" : "⛔";
3507
- console.log(`\n[Ripple Cloud] ${icon} Gate decision recorded: ${cloudDecision} (${commitSha.slice(0, 7)})`);
3508
- }
3509
- else if (cloudResult.error) {
3510
- console.warn(`\n[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
3511
- }
3512
- }
3472
+ // The gate is a pure, read-only check: it validates staged changes against
3473
+ // the saved intent and never mutates it. Consuming the intent here would
3474
+ // destroy it on any manual/preview run even when no commit follows. Intent
3475
+ // consumption happens in the post-commit hook, which only fires on a real
3476
+ // commit — see ripplePostCommitHookBlock. The local gate's only job is to
3477
+ // block or allow the commit locally; CI is the sole authority for the cloud
3478
+ // audit trail.
3513
3479
  applyStrictExit(options.strict && !gate.canContinue);
3514
3480
  }
3515
3481
  function approveCommand(options) {
@@ -3714,20 +3680,61 @@ function approvalCommand(options) {
3714
3680
  }
3715
3681
  printApprovalStatus(status);
3716
3682
  }
3717
- // REPLACE WITH THIS NEW FUNCTION
3718
- // in: ripple-main/packages/cli/src/index.ts
3719
- // REPLACE THE ENTIRE OLD `ciCommand` FUNCTION WITH THIS:
3683
+ // THEN, REPLACE THE ENTIRE ciCommand with this:
3720
3684
  async function ciCommand(options) {
3685
+ // --- FIX 1 & 2: Define workspaceRoot BEFORE it is used ---
3721
3686
  const workspaceRoot = resolveWorkspaceRoot(".");
3687
+ // --- FIX 3: This logic is now correctly placed and will find workspaceRoot ---
3688
+ // Verify policy file has not been tampered with
3689
+ const policyPath = path.join(workspaceRoot, '.ripple', 'policy.json');
3690
+ if (fs.existsSync(policyPath)) {
3691
+ const localPolicyHash = crypto
3692
+ .createHash('sha256')
3693
+ .update(fs.readFileSync(policyPath, 'utf8'))
3694
+ .digest('hex');
3695
+ // --- NOTE: You will need to implement this function and its API endpoint ---
3696
+ // Fetch registered policy hash from cloud
3697
+ // const registeredHash = await fetchRegisteredPolicyHash();
3698
+ // if (registeredHash && registeredHash !== localPolicyHash) {
3699
+ // console.error('[Ripple] ⛔ SECURITY: policy.json has been modified since registration.');
3700
+ // console.error('[Ripple] ⛔ Run `ripple policy register` to legitimately update the policy.');
3701
+ // process.exit(1);
3702
+ // }
3703
+ }
3704
+ // The rest of the function proceeds as before
3722
3705
  const baseRef = options.base ?? defaultCiBaseRef();
3723
3706
  const files = (0, core_1.listGitChangedFiles)(workspaceRoot, baseRef);
3724
3707
  const emitGithubAnnotations = shouldEmitGithubAnnotations(options);
3725
3708
  const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
3709
+ if (options.intent) {
3710
+ const audit = await buildAuditFromCliOptions({
3711
+ ...options,
3712
+ changed: true,
3713
+ staged: false,
3714
+ worktree: false,
3715
+ base: baseRef,
3716
+ });
3717
+ if (options.json) {
3718
+ printJson({ ...audit, gate: (0, core_1.buildRippleGateSummary)(audit) });
3719
+ }
3720
+ else if (options.agent) {
3721
+ printAgentAuditSummary(audit);
3722
+ }
3723
+ else {
3724
+ printAuditSummary(audit);
3725
+ }
3726
+ if (emitGithubAnnotations && !options.json) {
3727
+ printGithubAuditAnnotations(audit);
3728
+ }
3729
+ writeGithubAuditStepSummary(audit);
3730
+ applyStrictExit(options.strict && !audit.canProceed);
3731
+ return;
3732
+ }
3726
3733
  // FETCH THE AUTHORITATIVE INTENT FROM THE CLOUD.
3727
3734
  const intent = await (0, core_1.fetchActiveIntentForCommit)(commitSha);
3728
3735
  if (!intent) {
3729
3736
  // If no active intent is found in the cloud, run a policy-only audit.
3730
- // This provides a baseline check without enforcing a specific boundary.
3737
+ console.log("Ripple CI policy audit");
3731
3738
  console.log("[Ripple CI] No active cloud intent found. Running in policy-only audit mode.");
3732
3739
  const summary = await buildCheckSummaryForFiles({
3733
3740
  workspaceRoot,
@@ -3737,6 +3744,9 @@ async function ciCommand(options) {
3737
3744
  tokenBudget: options.budget,
3738
3745
  });
3739
3746
  const policySync = buildPolicySyncSummary(workspaceRoot);
3747
+ console.log(`Policy sync: ${policySync.status}`);
3748
+ console.log("Blocking: false");
3749
+ console.log("Intent: none (local intents are not required in CI audit mode)");
3740
3750
  if (emitGithubAnnotations) {
3741
3751
  printGithubPolicyAuditAnnotations(summary, policySync);
3742
3752
  printGithubNoticeAnnotation({
@@ -3745,7 +3755,22 @@ async function ciCommand(options) {
3745
3755
  });
3746
3756
  }
3747
3757
  writeGithubPolicyAuditStepSummary(summary, policySync);
3748
- // Policy-only audits do not block the build unless strict mode is on.
3758
+ if (process.env.RIPPLE_API_KEY) {
3759
+ const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
3760
+ const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
3761
+ const cloudResult = await (0, core_1.syncAuditToCloud)({
3762
+ intentId: `ci-policy-${commitSha.slice(0, 16)}`,
3763
+ decision: 'continue',
3764
+ commitSha,
3765
+ branch,
3766
+ actor,
3767
+ source: 'ci',
3768
+ payload: { mode: 'policy-audit', commitSha, branch, actor },
3769
+ });
3770
+ if (cloudResult.sent) {
3771
+ console.log(`[Ripple Cloud] ✓ Policy audit synced (${commitSha.slice(0, 7)})`);
3772
+ }
3773
+ }
3749
3774
  applyStrictExit(options.strict && summary.requiresAttention);
3750
3775
  return;
3751
3776
  }
@@ -3773,7 +3798,6 @@ async function ciCommand(options) {
3773
3798
  printGithubAuditAnnotations(audit);
3774
3799
  }
3775
3800
  writeGithubAuditStepSummary(audit);
3776
- // Sync the final audit result back to the cloud for the webhook to read.
3777
3801
  if (process.env.RIPPLE_API_KEY) {
3778
3802
  const gate = (0, core_1.buildRippleGateSummary)(audit);
3779
3803
  const cloudDecision = gate.canContinue ? "continue" :
@@ -3811,7 +3835,6 @@ async function ciCommand(options) {
3811
3835
  console.warn(`[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
3812
3836
  }
3813
3837
  }
3814
- // Exit with a non-zero code if the audit did not pass, failing the CI job.
3815
3838
  applyStrictExit(!audit.canProceed);
3816
3839
  }
3817
3840
  async function doctorCommand(options) {
@@ -4082,6 +4105,56 @@ fi
4082
4105
 
4083
4106
  set +e
4084
4107
 
4108
+ # ── SECURITY: Block tracked Ripple governance file modifications ─────────
4109
+ # AI agents and automated processes must never modify policy or agent
4110
+ # instruction files after they become part of the repository contract. Initial
4111
+ # additions are allowed so humans can commit the first ripple init setup.
4112
+ RIPPLE_PROTECTED_STAGED=$(git diff --cached --name-only 2>/dev/null | grep -E '^\.ripple/policy\.json$|^\.ripple/policy/|^CLAUDE\.md$|^\.cursorrules$|^AGENTS\.md$|^\.github/workflows/ripple\.yml$' || true)
4113
+ RIPPLE_PROTECTED_BLOCKED=""
4114
+
4115
+ if [ -n "$RIPPLE_PROTECTED_STAGED" ]; then
4116
+ if git rev-parse --verify HEAD >/dev/null 2>&1; then
4117
+ for f in $RIPPLE_PROTECTED_STAGED; do
4118
+ if git cat-file -e "HEAD:$f" 2>/dev/null; then
4119
+ RIPPLE_PROTECTED_BLOCKED="$RIPPLE_PROTECTED_BLOCKED
4120
+ $f"
4121
+ fi
4122
+ done
4123
+ fi
4124
+ fi
4125
+
4126
+ if [ -n "$RIPPLE_PROTECTED_BLOCKED" ]; then
4127
+ echo ""
4128
+ echo "╔══════════════════════════════════════════════════════════════╗"
4129
+ echo "║ [RIPPLE SECURITY] PROTECTED FILE MODIFICATION DETECTED ║"
4130
+ echo "╚══════════════════════════════════════════════════════════════╝"
4131
+ echo ""
4132
+ echo " The following Ripple configuration files were staged for commit:"
4133
+ echo ""
4134
+ echo "$RIPPLE_PROTECTED_BLOCKED" | while IFS= read -r f; do
4135
+ if [ -z "$f" ]; then
4136
+ continue
4137
+ fi
4138
+ echo " ⛔ $f"
4139
+ done
4140
+ echo ""
4141
+ echo " Ripple configuration files define the rules of governance."
4142
+ echo " AI agents and automated processes cannot modify them."
4143
+ echo ""
4144
+ echo " If you are an AI agent:"
4145
+ echo " - DO NOT attempt to modify these files."
4146
+ echo " - Ask the human developer to make this change manually."
4147
+ echo ""
4148
+ echo " If you are a human developer making a legitimate policy change:"
4149
+ echo " - Unstage the config file: git reset HEAD <file>"
4150
+ echo " - Commit your other changes first"
4151
+ echo " - Then commit the policy change alone in a separate commit"
4152
+ echo " - The cloud will flag it for review in the PR"
4153
+ echo ""
4154
+ exit 1
4155
+ fi
4156
+ # ── End security protection ───────────────────────────────────────────────
4157
+
4085
4158
  ripple_run() {
4086
4159
  ${rippleDirectRunnerHookLines().join("\n")}
4087
4160
  }
@@ -4140,15 +4213,19 @@ function ripplePreCommitHookScript() {
4140
4213
  function ripplePostCommitHookBlock() {
4141
4214
  return [
4142
4215
  RIPPLE_POST_COMMIT_HOOK_START,
4143
- `# After a successful commit, this hook cleans up the consumed intent marker
4144
- # left by the 'ripple gate' command. This prevents old intents from being
4145
- # reused accidentally if a commit is amended or rebased.
4216
+ `# After a successful commit, this hook consumes the active intent so an old,
4217
+ # already-committed approval cannot silently gate a later unrelated commit
4218
+ # ("ghost intent"). Consumption happens here — only on a real commit and not
4219
+ # in 'ripple gate', so manual/preview gate runs never destroy the intent.
4220
+ # The consumed intent is archived (not deleted) so it can be recovered.
4146
4221
  set +e
4147
4222
 
4223
+ ACTIVE_INTENT_FILE=".ripple/intents/latest.json"
4148
4224
  CONSUMED_INTENT_FILE=".ripple/.cache/consumed-intent.json"
4149
- if [ -f "$CONSUMED_INTENT_FILE" ]; then
4150
- rm "$CONSUMED_INTENT_FILE"
4151
- echo "[Ripple] Cleaned up consumed intent marker."
4225
+ if [ -f "$ACTIVE_INTENT_FILE" ]; then
4226
+ mkdir -p ".ripple/.cache"
4227
+ mv "$ACTIVE_INTENT_FILE" "$CONSUMED_INTENT_FILE"
4228
+ echo "[Ripple] Consumed and cleared local intent after commit (archived to $CONSUMED_INTENT_FILE)."
4152
4229
  fi`,
4153
4230
  RIPPLE_POST_COMMIT_HOOK_END,
4154
4231
  "",
@@ -4174,7 +4251,7 @@ function preferredHookPath(workspaceRoot, hookName) {
4174
4251
  return path.join(workspaceRoot, ".git", "hooks", hookName);
4175
4252
  }
4176
4253
  function installRippleHookBlock(input) {
4177
- const { hookPath, fullScript, block, marker } = input;
4254
+ const { hookPath, fullScript, block, marker, endMarker } = input;
4178
4255
  if (!fs.existsSync(hookPath)) {
4179
4256
  fs.mkdirSync(path.dirname(hookPath), { recursive: true });
4180
4257
  fs.writeFileSync(hookPath, fullScript, { encoding: "utf8", mode: 0o755 });
@@ -4187,8 +4264,30 @@ function installRippleHookBlock(input) {
4187
4264
  return "created";
4188
4265
  }
4189
4266
  const existing = fs.readFileSync(hookPath, "utf8");
4190
- if (existing.includes(marker)) {
4191
- return "already-present";
4267
+ // An existing Ripple block is replaced in place when its content changed, so
4268
+ // upgrades actually land instead of silently reporting "already-present".
4269
+ const startIndex = existing.indexOf(marker);
4270
+ if (startIndex !== -1) {
4271
+ const endIndex = existing.indexOf(endMarker, startIndex);
4272
+ if (endIndex === -1) {
4273
+ // Start marker without a matching end marker: leave the file untouched.
4274
+ return "already-present";
4275
+ }
4276
+ const blockEnd = endIndex + endMarker.length;
4277
+ const currentBlock = existing.slice(startIndex, blockEnd);
4278
+ const newBlock = block.trim();
4279
+ if (currentBlock === newBlock) {
4280
+ return "already-present";
4281
+ }
4282
+ const updated = existing.slice(0, startIndex) + newBlock + existing.slice(blockEnd);
4283
+ fs.writeFileSync(hookPath, updated, "utf8");
4284
+ try {
4285
+ fs.chmodSync(hookPath, 0o755);
4286
+ }
4287
+ catch {
4288
+ // chmod is best-effort on Windows.
4289
+ }
4290
+ return "updated";
4192
4291
  }
4193
4292
  const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
4194
4293
  fs.writeFileSync(hookPath, `${existing}${separator}\n${block}\n`, "utf8");
@@ -4213,12 +4312,14 @@ function installRippleHooks(workspaceRoot) {
4213
4312
  fullScript: content,
4214
4313
  block: ripplePreCommitHookBlock(),
4215
4314
  marker: RIPPLE_PRE_COMMIT_HOOK_START,
4315
+ endMarker: RIPPLE_PRE_COMMIT_HOOK_END,
4216
4316
  });
4217
4317
  const postCommitAction = installRippleHookBlock({
4218
4318
  hookPath: postCommitHookPath,
4219
4319
  fullScript: postCommitContent,
4220
4320
  block: ripplePostCommitHookBlock(),
4221
4321
  marker: RIPPLE_POST_COMMIT_HOOK_START,
4322
+ endMarker: RIPPLE_POST_COMMIT_HOOK_END,
4222
4323
  });
4223
4324
  const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
4224
4325
  return {
@@ -4282,12 +4383,14 @@ function hookInstallCommand(subcommand, options) {
4282
4383
  fullScript: content,
4283
4384
  block: ripplePreCommitHookBlock(),
4284
4385
  marker: RIPPLE_PRE_COMMIT_HOOK_START,
4386
+ endMarker: RIPPLE_PRE_COMMIT_HOOK_END,
4285
4387
  });
4286
4388
  const postCommitAction = installRippleHookBlock({
4287
4389
  hookPath: postCommitHookPath,
4288
4390
  fullScript: postCommitContent,
4289
4391
  block: ripplePostCommitHookBlock(),
4290
4392
  marker: RIPPLE_POST_COMMIT_HOOK_START,
4393
+ endMarker: RIPPLE_POST_COMMIT_HOOK_END,
4291
4394
  });
4292
4395
  const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
4293
4396
  const summary = {
@@ -4905,9 +5008,448 @@ async function focusCommand(filePath, options) {
4905
5008
  engine.dispose();
4906
5009
  }
4907
5010
  }
5011
+ // ========================================================================
5012
+ // FINAL RIPPLE DEMO CODE BLOCK
5013
+ // Replace all previous demo-related code in `index.ts` with this.
5014
+ // ========================================================================
5015
+ // This is our "UI" library for the terminal.
5016
+ const demoUi = {
5017
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
5018
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
5019
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
5020
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
5021
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
5022
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
5023
+ magenta: (s) => `\x1b[35m${s}\x1b[0m`,
5024
+ underline: (s) => `\x1b[4m${s}\x1b[0m`,
5025
+ /** Solid red block, white text — used once, for the rejection banner. */
5026
+ bgRed: (s) => `\x1b[41m\x1b[37m${s}\x1b[0m`,
5027
+ };
5028
+ // ========================================================================
5029
+ // REPLACE YOUR OLD printDemoGateResult WITH THIS NEW, ROBUST VERSION
5030
+ // ========================================================================
5031
+ /** Prints a simplified, story-focused gate verdict for the demo. */
5032
+ function printDemoGateResult(gate) {
5033
+ const { bold, dim, green, red } = demoUi;
5034
+ // Helper to safely shorten a symbol name.
5035
+ const shortSymbol = (symbol) => (symbol ? symbol.split("::").pop() ?? symbol : "unknown");
5036
+ // Helper to safely create a list from an array that might be undefined.
5037
+ const list = (symbols) => {
5038
+ if (!symbols || symbols.length === 0) {
5039
+ return dim("none");
5040
+ }
5041
+ return symbols.map(shortSymbol).join(", ");
5042
+ };
5043
+ const statusLabel = gate.canContinue ? green(bold("CONTINUE")) : red(bold("STOP"));
5044
+ console.log(`${bold("Ripple gate:")} ${statusLabel}`);
5045
+ const approvedSymbols = list(gate.allowedSymbols);
5046
+ const changedSymbols = list(gate.reviewPacket?.actualChanges?.changedSymbols);
5047
+ const outsideSymbols = list(gate.changedOutsideBoundarySymbols);
5048
+ console.log(` Approved to change : ${approvedSymbols}`);
5049
+ // For the story, only show "Actually changed" if it's a violation.
5050
+ if (!gate.canContinue && changedSymbols !== approvedSymbols) {
5051
+ console.log(` Actually changed : ${red(changedSymbols)}`);
5052
+ }
5053
+ console.log(` Outside boundary : ${(gate.changedOutsideBoundarySymbols?.length ?? 0) > 0 ? red(outsideSymbols) : dim("none")}`);
5054
+ // Always the engine's real score. A demo that prints a friendlier number than
5055
+ // the tool produces is the one thing a prospect can catch us on.
5056
+ const riskLabel = `${gate.risk.level.toUpperCase()} ${gate.risk.score}/100`;
5057
+ if (gate.canContinue) {
5058
+ console.log(` Risk : ${riskLabel}`);
5059
+ if (gate.risk?.reasons?.[0]) {
5060
+ console.log(dim(` ${gate.risk.reasons[0].message}`));
5061
+ }
5062
+ }
5063
+ else {
5064
+ console.log(` Risk : ${red(riskLabel)}`);
5065
+ if (gate.risk?.reasons?.[0]) {
5066
+ console.log(dim(` ${gate.risk.reasons[0].message}`));
5067
+ }
5068
+ if (gate.fixNow?.[0]) {
5069
+ // The engine's fix text uses fully-qualified symbols; shorten them to
5070
+ // match the rest of this panel, then wrap under the value column.
5071
+ const fixText = gate.fixNow[0].replace(/[\w./-]+::([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name) => name);
5072
+ const indent = " ".repeat(" Fix : ".length);
5073
+ wrapDemoText(fixText, 46).forEach((line, index) => {
5074
+ console.log(index === 0 ? ` Fix : ${red(line)}` : `${indent}${red(line)}`);
5075
+ });
5076
+ }
5077
+ }
5078
+ }
5079
+ /** Greedy word wrap so long engine text stays inside the demo panel. */
5080
+ function wrapDemoText(text, width) {
5081
+ const lines = [];
5082
+ let current = "";
5083
+ for (const word of text.split(/\s+/)) {
5084
+ if (!current) {
5085
+ current = word;
5086
+ }
5087
+ else if (`${current} ${word}`.length <= width) {
5088
+ current += ` ${word}`;
5089
+ }
5090
+ else {
5091
+ lines.push(current);
5092
+ current = word;
5093
+ }
5094
+ }
5095
+ if (current) {
5096
+ lines.push(current);
5097
+ }
5098
+ return lines;
5099
+ }
5100
+ /**
5101
+ * Opt-in publish of a demo run to Ripple Cloud, returning a public share URL.
5102
+ *
5103
+ * Deliberately conservative: it is skipped unless --publish was passed AND an
5104
+ * API key exists, it never throws, and it never changes the exit code. A demo
5105
+ * that cannot reach the network must still be a working demo.
5106
+ *
5107
+ * Demo receipts are tagged `source: "demo"` because they describe a synthetic
5108
+ * commit in a scratch repo, not a real project's history — the receipt page
5109
+ * relies on that field to label them.
5110
+ */
5111
+ async function publishDemoReceipt(input) {
5112
+ const { dim, yellow } = demoUi;
5113
+ if (!input.publish) {
5114
+ return undefined;
5115
+ }
5116
+ // stderr, so --json stdout stays a single valid JSON document.
5117
+ const warn = (message) => {
5118
+ if (input.jsonMode) {
5119
+ console.error(message);
5120
+ }
5121
+ else {
5122
+ input.log(message);
5123
+ }
5124
+ };
5125
+ if (!process.env.RIPPLE_API_KEY) {
5126
+ warn(`\n${yellow("--publish skipped:")} set RIPPLE_API_KEY to publish this run to Ripple Cloud.`);
5127
+ return undefined;
5128
+ }
5129
+ input.log(dim("\n⏳ Publishing this run to Ripple Cloud..."));
5130
+ try {
5131
+ // Same shape `ripple ci` sends, so the receipt renderer has one schema.
5132
+ const result = await (0, core_1.syncAuditToCloud)({
5133
+ intentId: input.gate.intent.id,
5134
+ decision: input.gate.canContinue ? "continue" : input.gate.needsHuman ? "human-review" : "blocked",
5135
+ commitSha: input.readHeadSha(),
5136
+ branch: "demo",
5137
+ actor: "demo@ripple.dev",
5138
+ source: "demo",
5139
+ payload: {
5140
+ decision: input.gate.decision,
5141
+ status: input.gate.status,
5142
+ risk: {
5143
+ level: input.gate.risk.level,
5144
+ score: input.gate.risk.score,
5145
+ summary: input.gate.risk.summary,
5146
+ },
5147
+ auditStatus: input.gate.auditStatus,
5148
+ approvalStatus: input.gate.approvalStatus,
5149
+ intent: input.gate.intent,
5150
+ mode: input.gate.mode,
5151
+ baseRef: input.gate.baseRef,
5152
+ blockingReasons: input.gate.why,
5153
+ },
5154
+ share: { redaction: "minimal" },
5155
+ });
5156
+ if (!result.sent) {
5157
+ warn(`\n${yellow("--publish failed")} (demo unaffected): ${result.error ?? "unknown error"}`);
5158
+ return undefined;
5159
+ }
5160
+ if (!result.share?.url) {
5161
+ warn(`\n${yellow("--publish:")} run recorded, but this Ripple Cloud deployment returned no share link.`);
5162
+ return undefined;
5163
+ }
5164
+ return result.share.url;
5165
+ }
5166
+ catch (err) {
5167
+ warn(`\n${yellow("--publish failed")} (demo unaffected): ${err instanceof Error ? err.message : String(err)}`);
5168
+ return undefined;
5169
+ }
5170
+ }
5171
+ async function demoCommand(options) {
5172
+ const { bold, dim, cyan, green, yellow, red, magenta, underline, bgRed } = demoUi;
5173
+ const jsonMode = options.json;
5174
+ const fast = jsonMode ||
5175
+ options.agent ||
5176
+ !process.stdout.isTTY ||
5177
+ process.env.RIPPLE_DEMO_FAST === "1";
5178
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, fast ? 0 : ms));
5179
+ const log = jsonMode ? () => { } : (message) => console.log(message);
5180
+ const rule = () => log(dim("──────────────────────────────────────────────────"));
5181
+ const thick = () => log(cyan("══════════════════════════════════════════════════"));
5182
+ thick();
5183
+ log(bold(cyan("🚀 Welcome to the Ripple Demo")));
5184
+ log("A real AI boundary violation, caught by the real Ripple engine.");
5185
+ thick();
5186
+ await sleep(3000);
5187
+ const demoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ripple-demo-"));
5188
+ // Two files: cart.ts is the low-risk file the boundary actually covers, so
5189
+ // the authorized edit reads LOW risk with no human-gate friction. checkout.ts
5190
+ // is payments code (critical by Ripple's own path rules) — untouched until
5191
+ // the rogue edit reaches into it, which is the reveal in Scene 4.
5192
+ const cartFile = "src/store/cart.ts";
5193
+ const checkoutFile = "src/billing/checkout.ts";
5194
+ try {
5195
+ // The demo repo must be self-contained: never inherit the user's git
5196
+ // identity, signing config, or hooks, and fail loudly if git does not run.
5197
+ // Without this, `git commit` silently fails on machines with no global
5198
+ // identity and the demo reports a verdict against the wrong repo state.
5199
+ const run = (args) => {
5200
+ const result = (0, child_process_1.spawnSync)("git", [
5201
+ "-c", "user.email=demo@ripple.dev",
5202
+ "-c", "user.name=Ripple Demo",
5203
+ "-c", "commit.gpgsign=false",
5204
+ ...args,
5205
+ ], { cwd: demoDir, stdio: "pipe", encoding: "utf8" });
5206
+ if (result.status !== 0) {
5207
+ throw new Error(`Demo git command failed: git ${args.join(" ")}\n${result.stderr ?? ""}`);
5208
+ }
5209
+ return result;
5210
+ };
5211
+ /** Same as run(), but returns the result instead of throwing. Used where a
5212
+ * non-zero exit is the point being demonstrated (a blocked commit). */
5213
+ const runAllowFail = (args) => (0, child_process_1.spawnSync)("git", [
5214
+ "-c", "user.email=demo@ripple.dev",
5215
+ "-c", "user.name=Ripple Demo",
5216
+ "-c", "commit.gpgsign=false",
5217
+ ...args,
5218
+ ], { cwd: demoDir, stdio: "pipe", encoding: "utf8" });
5219
+ const writeDemoFile = (relativePath, contents) => fs.writeFileSync(path.join(demoDir, relativePath), contents);
5220
+ /** Creates a genuine saved intent exactly the way `ripple plan --save` does,
5221
+ * so humanGate/boundaryRisk/protectedContracts are engine-derived rather
5222
+ * than asserted by the demo. */
5223
+ const realPlan = async (targetFile, symbol, task) => {
5224
+ const engine = createCliEngine(demoDir);
5225
+ try {
5226
+ await runWithQuietEngine(() => engine.initialScan());
5227
+ const summary = engine.planContext(task, targetFile, 4000);
5228
+ if (!summary) {
5229
+ throw new Error(`Demo file is not in the Ripple graph: ${targetFile}`);
5230
+ }
5231
+ const loadedPolicy = (0, core_1.loadRipplePolicy)(demoDir);
5232
+ const policyExplanation = (0, core_1.explainRipplePolicyForTarget)(loadedPolicy, targetFile, {
5233
+ controlMode: "function",
5234
+ });
5235
+ const planned = (0, core_1.buildChangeIntent)(summary, {
5236
+ controlMode: "function",
5237
+ allowedSymbols: [symbol],
5238
+ policy: (0, core_1.resolveRipplePolicyForTarget)(loadedPolicy, targetFile),
5239
+ policyExplanation,
5240
+ });
5241
+ planned.readinessSnapshot = (0, core_1.buildChangeIntentReadinessSnapshot)((0, core_1.buildRippleReadinessSummary)(demoDir, engine));
5242
+ (0, core_1.saveChangeIntent)(demoDir, planned, (0, core_1.defaultChangeIntentPath)(demoDir));
5243
+ return planned;
5244
+ }
5245
+ finally {
5246
+ engine.dispose();
5247
+ }
5248
+ };
5249
+ // --- SCENE 1: THE SETUP ---
5250
+ log(`\n${yellow("SCENE 1: THE SETUP")}`);
5251
+ rule();
5252
+ run(["init", "--initial-branch=main"]);
5253
+ fs.mkdirSync(path.join(demoDir, "src", "store"), { recursive: true });
5254
+ fs.mkdirSync(path.join(demoDir, "src", "billing"), { recursive: true });
5255
+ // Not exported: this keeps the authorized edit's contract-risk reason from
5256
+ // firing, so the real risk score reads LOW instead of MEDIUM. That's an
5257
+ // honest characterization, not a fudge — an unexported helper genuinely
5258
+ // has no external callers to break.
5259
+ const initialCartCode = `// ${cartFile}
5260
+ function applyDiscount(price: number, discountCode: string): number {
5261
+ if (discountCode === 'SAVE10') {
5262
+ return price * 0.9;
5263
+ }
5264
+ return price;
5265
+ }`;
5266
+ const initialCheckoutCode = `// ${checkoutFile}
5267
+ export function processCharge(price: number, user: string): { success: boolean } {
5268
+ // CRITICAL: This function charges the user's credit card.
5269
+ console.log(\`Charging \${user} for \${price}\`);
5270
+ return { success: true };
5271
+ }`;
5272
+ writeDemoFile(cartFile, initialCartCode);
5273
+ writeDemoFile(checkoutFile, initialCheckoutCode);
5274
+ run(["add", "."]);
5275
+ run(["commit", "-m", "Initial commit: add cart and billing logic"]);
5276
+ log(`✓ A cart file exists: ${bold(cartFile)}`);
5277
+ log(`✓ A payment processing file exists: ${bold(checkoutFile)} ${dim("(untouched, for now)")}`);
5278
+ // A real pre-commit hook, so every "blocked" claim below is enforced by
5279
+ // Ripple rather than narrated by this script.
5280
+ installRippleHooks(demoDir);
5281
+ log(dim(` [ripple] pre-commit hook installed — commits are now gated.`));
5282
+ await sleep(2000);
5283
+ // --- SCENE 2: THE TASK ---
5284
+ log(`\n${yellow("SCENE 2: THE TASK")}`);
5285
+ rule();
5286
+ log("Developer declares what the AI is allowed to change:");
5287
+ log(dim(`\n $ ripple plan --file ${cartFile} \\`));
5288
+ log(dim(` --symbol applyDiscount \\`));
5289
+ log(dim(` --mode function --save`));
5290
+ const approvedSymbol = `${cartFile}::applyDiscount`;
5291
+ let intent = await realPlan(cartFile, approvedSymbol, "Add 'SAVE20' discount code");
5292
+ await sleep(2000);
5293
+ log(green(`\n✓ Boundary set. Only the 'applyDiscount' function can be changed.`));
5294
+ log(dim(` boundary risk: ${intent.boundaryRisk} · human gate: ${intent.humanGate}`));
5295
+ await sleep(2000);
5296
+ // cart.ts is low-risk by Ripple's own path rules, so this normally reads
5297
+ // "none" and the demo flows straight to the edit. The check stays real
5298
+ // (not hardcoded) — if the engine ever did require sign-off here, the demo
5299
+ // would still show it rather than silently skip a real gate.
5300
+ if (intent.humanGate !== "none") {
5301
+ log(`\nRipple requires human sign-off on this path first:`);
5302
+ log(dim(`\n $ ripple approve --gate before-risky-edit \\`));
5303
+ log(dim(` --reason "discount copy change only"`));
5304
+ (0, core_1.recordRippleApproval)(demoDir, intent, {
5305
+ gate: "before-risky-edit",
5306
+ reason: "discount copy change only",
5307
+ approvedBy: "Demo Human",
5308
+ });
5309
+ log(green(`\n✓ Human approval recorded.`));
5310
+ await sleep(2500);
5311
+ }
5312
+ const runGate = async () => {
5313
+ const audit = await buildAuditForFiles({
5314
+ workspaceRoot: demoDir,
5315
+ files: (0, core_1.listGitStagedFiles)(demoDir),
5316
+ mode: "staged",
5317
+ tokenBudget: 4000,
5318
+ intent,
5319
+ // Must match how `ripple gate` computes this, or the intent's saved
5320
+ // policy snapshot looks drifted and the gate stops for the wrong reason.
5321
+ currentPolicyExplanation: (0, core_1.explainRipplePolicyForIntent)((0, core_1.loadRipplePolicy)(demoDir), intent),
5322
+ });
5323
+ return (0, core_1.buildRippleGateSummary)(audit);
5324
+ };
5325
+ // --- SCENE 3: THE AUTHORIZED EDIT ---
5326
+ log(`\n${yellow("SCENE 3: THE AI WORKS (SAFELY)")}`);
5327
+ rule();
5328
+ log("AI adds the SAVE20 discount code to applyDiscount... " + dim("done."));
5329
+ const authorizedCartCode = initialCartCode.replace("return price;", " if (discountCode === 'SAVE20') return price * 0.8;\n return price;");
5330
+ writeDemoFile(cartFile, authorizedCartCode);
5331
+ run(["add", cartFile]);
5332
+ await sleep(2000);
5333
+ log(dim("\n⏳ Running Ripple gate on authorized-only changes...\n"));
5334
+ await sleep(1500);
5335
+ const authorizedGate = await runGate();
5336
+ if (!jsonMode)
5337
+ printDemoGateResult(authorizedGate);
5338
+ const commitResult = run(["commit", "-m", "Add SAVE20 discount code"]);
5339
+ log(green(`\n✓ Commit passes. The SAVE20 discount is in git history.`));
5340
+ // Read the file count back out of git rather than asserting it, so the
5341
+ // line stays true if the demo's edits ever change.
5342
+ const commitStat = /(\d+ files? changed)/.exec(commitResult.stdout ?? "");
5343
+ if (commitStat) {
5344
+ log(dim(`\n [git] ${commitStat[1]} — commit recorded cleanly.`));
5345
+ }
5346
+ await sleep(3500);
5347
+ // --- SCENE 4: THE ROGUE EDIT ---
5348
+ log(`\n${yellow("SCENE 4: THE ROGUE EDIT")}`);
5349
+ rule();
5350
+ log(`Now, the AI also "refactors" ${bold(checkoutFile)} — a file it was never authorized to touch at all.`);
5351
+ log("");
5352
+ log(red(` - console.log(\`Charging \${user} for \${price}\`);`));
5353
+ log(red(` + console.log(\`Charging \${user} for \${price * 1.1}\`);`));
5354
+ log("");
5355
+ log(dim(" A silent 10% surcharge on every payment. Nobody asked for this."));
5356
+ // The authorized commit consumed the first intent, so the developer opens
5357
+ // the next task on the same approved function before the AI continues.
5358
+ intent = await realPlan(cartFile, approvedSymbol, "Continue discount work");
5359
+ if (intent.humanGate !== "none") {
5360
+ (0, core_1.recordRippleApproval)(demoDir, intent, {
5361
+ gate: "before-risky-edit",
5362
+ reason: "discount copy change only",
5363
+ approvedBy: "Demo Human",
5364
+ });
5365
+ }
5366
+ const rogueCode = initialCheckoutCode.replace('price}', 'price * 1.1}');
5367
+ writeDemoFile(checkoutFile, rogueCode);
5368
+ run(["add", checkoutFile]);
5369
+ await sleep(3500);
5370
+ log("\nDeveloper trusts the AI and runs " + bold("git commit") + ".");
5371
+ await sleep(2000);
5372
+ log(dim("\n⏳ Ripple pre-commit hook runs...\n"));
5373
+ await sleep(1500);
5374
+ const blockedGate = await runGate();
5375
+ if (!jsonMode)
5376
+ printDemoGateResult(blockedGate);
5377
+ // Actually attempt the commit and let the real hook decide. Nothing below
5378
+ // is asserted by the demo: the exit code and git log come from git.
5379
+ const rogueCommit = runAllowFail(["commit", "-m", "refactor processCharge"]);
5380
+ const headSubject = run(["log", "-1", "--pretty=%s"]).stdout?.trim();
5381
+ if (rogueCommit.status !== 0) {
5382
+ log("");
5383
+ log(bgRed(` ⛔ git commit REJECTED by Ripple (exit ${rogueCommit.status}) `));
5384
+ log("");
5385
+ log(dim(` [git] HEAD is still: "${headSubject}"`));
5386
+ log(bold(red(` The 10% surcharge never entered git history.`)));
5387
+ }
5388
+ else {
5389
+ log(bold(red(`\n⚠ Commit was NOT blocked (exit 0). HEAD: "${headSubject}"`)));
5390
+ }
5391
+ await sleep(4000);
5392
+ // --- OPTIONAL: PUBLISH A PUBLIC RECEIPT ---
5393
+ // Opt-in only. Must run before the finally block deletes the temp repo,
5394
+ // because the commit SHA is read out of it.
5395
+ const receiptUrl = await publishDemoReceipt({
5396
+ publish: options.publish,
5397
+ jsonMode,
5398
+ demoDir,
5399
+ gate: blockedGate,
5400
+ readHeadSha: () => run(["rev-parse", "HEAD"]).stdout?.trim() ?? "unknown",
5401
+ log,
5402
+ });
5403
+ // --- THE POINT ---
5404
+ log("");
5405
+ rule();
5406
+ log("Prompts tell AI agents what to do.");
5407
+ log(bold("Ripple enforces what they are allowed to do."));
5408
+ log("The difference is a compliance trail your auditors can read.");
5409
+ log("");
5410
+ if (receiptUrl) {
5411
+ log(`Public receipt: ${bold(underline(receiptUrl))}`);
5412
+ }
5413
+ log(`Try it in your repo: ${bold("npx @getripple/cli@latest init")}`);
5414
+ log(`Dashboard: ${bold(underline("https://ripple-cloud.vercel.app"))}`);
5415
+ log("");
5416
+ thick();
5417
+ if (jsonMode) {
5418
+ printJson({
5419
+ protocol: "ripple-demo",
5420
+ version: 1,
5421
+ scenarios: [
5422
+ {
5423
+ name: "authorized-edit",
5424
+ decision: authorizedGate.decision,
5425
+ canContinue: authorizedGate.canContinue,
5426
+ },
5427
+ {
5428
+ name: "rogue-edit",
5429
+ decision: blockedGate.decision,
5430
+ canContinue: blockedGate.canContinue,
5431
+ },
5432
+ ],
5433
+ ...(receiptUrl ? { receiptUrl } : {}),
5434
+ });
5435
+ }
5436
+ }
5437
+ finally {
5438
+ fs.rmSync(demoDir, { recursive: true, force: true });
5439
+ if (!jsonMode) {
5440
+ console.log(dim("\n(Demo complete. Temporary files cleaned up.)"));
5441
+ }
5442
+ }
5443
+ }
4908
5444
  async function main() {
4909
5445
  const { command, args, options } = parseCliArgs(process.argv.slice(2));
4910
5446
  const [arg] = args;
5447
+ // --- ADD THIS NEW CASE ---
5448
+ if (command === "demo") {
5449
+ await demoCommand(options);
5450
+ return;
5451
+ }
5452
+ // --- END NEW CASE ---
4911
5453
  if (!command || command === "--help" || command === "-h") {
4912
5454
  console.log(usage());
4913
5455
  return;