@getripple/cli 1.0.8 → 1.0.10
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 +25 -0
- package/README.md +142 -415
- package/dist/index.js +1840 -63
- package/dist/index.js.map +1 -1
- package/package.json +53 -53
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
const fs = __importStar(require("fs"));
|
|
28
28
|
const path = __importStar(require("path"));
|
|
29
|
+
const child_process_1 = require("child_process");
|
|
29
30
|
const core_1 = require("@getripple/core");
|
|
30
31
|
const CONTROL_MODES = ["brainstorm", "function", "file", "task", "pr"];
|
|
31
32
|
function usage() {
|
|
@@ -45,18 +46,25 @@ function usage() {
|
|
|
45
46
|
" ripple callers <file>::<symbol>",
|
|
46
47
|
" ripple history [--last N]",
|
|
47
48
|
" ripple plan --file <file> --task <task> [--mode file|function|brainstorm|task|pr] [--symbol name] [--budget N] [--save]",
|
|
49
|
+
" ripple intent status [--intent latest|path]",
|
|
50
|
+
" ripple intent close --reason text [--intent latest|path]",
|
|
48
51
|
" ripple check --staged [--intent latest|path] [--strict]",
|
|
52
|
+
" ripple check --worktree [--intent latest|path] [--strict]",
|
|
49
53
|
" ripple check --changed --base <ref> [--intent latest|path] [--strict]",
|
|
50
|
-
" ripple audit [--intent latest|path] [--changed --base <ref>] [--strict]",
|
|
51
|
-
" ripple gate [--intent latest|path] [--changed --base <ref>] [--strict]",
|
|
54
|
+
" ripple audit [--intent latest|path] [--worktree|--changed --base <ref>] [--strict]",
|
|
55
|
+
" ripple gate [--intent latest|path] [--worktree|--changed --base <ref>] [--strict]",
|
|
52
56
|
" ripple approval [--intent latest|path] [--gate before-risky-edit|before-merge]",
|
|
53
|
-
" ripple approve [--intent latest|path] [--gate before-risky-edit|before-merge]
|
|
57
|
+
" ripple approve [--intent latest|path] [--gate before-risky-edit|before-merge] --reason text",
|
|
58
|
+
" ripple verify --run <test command> [--intent latest|path] [--note text]",
|
|
59
|
+
" ripple verify --command <test command> --status passed|failed|skipped|unknown [--intent latest|path] [--note text]",
|
|
54
60
|
" ripple repair [--intent latest|path] [--strict]",
|
|
55
61
|
" ripple ci [--base <ref>] [--intent latest|path] [--github-annotations]",
|
|
56
62
|
" ripple init-ci [--print] [--force]",
|
|
57
63
|
" ripple policy init [--print] [--force]",
|
|
58
64
|
" ripple policy explain --file <file>",
|
|
59
65
|
" ripple agent",
|
|
66
|
+
" ripple agent setup [--print] [--force]",
|
|
67
|
+
" ripple hook install [--print] [--force]",
|
|
60
68
|
"",
|
|
61
69
|
"Options:",
|
|
62
70
|
" --json, -j Print machine-readable JSON",
|
|
@@ -67,14 +75,15 @@ function usage() {
|
|
|
67
75
|
" --mode MODE Agent control boundary for saved plans (default: file)",
|
|
68
76
|
" --symbol NAME Allowed symbol for --mode function",
|
|
69
77
|
" --gate GATE Human approval gate for approve (before-risky-edit or before-merge)",
|
|
70
|
-
" --reason TEXT
|
|
78
|
+
" --reason TEXT Required human approval reason",
|
|
71
79
|
" --approved-by NAME Human approver name for approval records",
|
|
72
80
|
" --budget N Token budget for plan (default: 4000)",
|
|
73
81
|
" --staged Check currently staged JS/TS files",
|
|
74
82
|
" --changed Check JS/TS files changed against --base",
|
|
83
|
+
" --worktree Check unstaged working-tree JS/TS changes",
|
|
75
84
|
" --base REF Base git ref for --changed checks (default: HEAD)",
|
|
76
85
|
" --save Save a change intent from ripple plan",
|
|
77
|
-
" --intent REF Validate changes against saved intent (latest, id, or path;
|
|
86
|
+
" --intent REF Validate changes against saved intent (latest, id, or path; local checks only)",
|
|
78
87
|
" --strict Exit non-zero when check/repair detects missing intent, drift, or contract danger",
|
|
79
88
|
" --github-annotations Emit GitHub Actions annotations for CI findings",
|
|
80
89
|
" --print Print generated setup content instead of writing files",
|
|
@@ -86,10 +95,14 @@ function usage() {
|
|
|
86
95
|
" ripple workflow",
|
|
87
96
|
" ripple doctor --agent",
|
|
88
97
|
" ripple agent",
|
|
98
|
+
" ripple agent setup",
|
|
89
99
|
" ripple agent --json",
|
|
90
100
|
" ripple plan --file src/auth.ts --task \"change token refresh behavior\" --mode file --agent --save",
|
|
91
101
|
" ripple plan --file src/auth.ts --symbol refreshToken --task \"fix retry behavior\" --mode function --agent --save",
|
|
102
|
+
" ripple intent status",
|
|
103
|
+
" ripple intent close --reason \"task finished\"",
|
|
92
104
|
" ripple check --staged --agent --intent latest",
|
|
105
|
+
" ripple check --worktree --agent --intent latest",
|
|
93
106
|
" ripple audit --agent --intent latest",
|
|
94
107
|
" ripple gate --agent --intent latest",
|
|
95
108
|
" ripple approval --intent latest --agent",
|
|
@@ -97,7 +110,7 @@ function usage() {
|
|
|
97
110
|
" ripple repair --agent --intent latest",
|
|
98
111
|
" ripple check --staged --intent latest --strict",
|
|
99
112
|
" ripple check --changed --base origin/main --strict",
|
|
100
|
-
" ripple ci --base origin/main --
|
|
113
|
+
" ripple ci --base origin/main --github-annotations",
|
|
101
114
|
" ripple init",
|
|
102
115
|
" ripple init-ci",
|
|
103
116
|
" ripple policy init",
|
|
@@ -139,6 +152,9 @@ function agentWorkflowGuide() {
|
|
|
139
152
|
` ${workflow.commands.auditCurrentChange}`,
|
|
140
153
|
` ${workflow.commands.gateCurrentChange}`,
|
|
141
154
|
"",
|
|
155
|
+
"Record verification evidence:",
|
|
156
|
+
` ${workflow.commands.recordVerification}`,
|
|
157
|
+
"",
|
|
142
158
|
"If staged changes drift:",
|
|
143
159
|
` ${workflow.commands.repairIntentDrift}`,
|
|
144
160
|
"",
|
|
@@ -158,6 +174,135 @@ function agentWorkflowGuide() {
|
|
|
158
174
|
...workflow.example.map((command) => ` ${command}`),
|
|
159
175
|
].join("\n");
|
|
160
176
|
}
|
|
177
|
+
function mcpServerConfig(workspaceRoot) {
|
|
178
|
+
return {
|
|
179
|
+
mcpServers: {
|
|
180
|
+
ripple: {
|
|
181
|
+
command: "npx",
|
|
182
|
+
args: ["-y", "@getripple/mcp", "--workspace", workspaceRoot],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function mcpServerConfigJson(workspaceRoot) {
|
|
188
|
+
return JSON.stringify(mcpServerConfig(workspaceRoot), null, 2);
|
|
189
|
+
}
|
|
190
|
+
const RIPPLE_AGENT_SETUP_FILE_NAMES = ["AGENTS.md", "CLAUDE.md", ".cursorrules"];
|
|
191
|
+
const RIPPLE_DEFAULT_AGENT_SETUP_FILE = ".cursorrules";
|
|
192
|
+
function agentInstructionMarkdown(_workspaceRoot, _fileName) {
|
|
193
|
+
const workflow = (0, core_1.getAgentWorkflowSummary)();
|
|
194
|
+
return [
|
|
195
|
+
"# RIPPLE AGENT PROTOCOL",
|
|
196
|
+
"You are connected to Ripple MCP for this repo.",
|
|
197
|
+
`1. BEFORE editing: MUST call \`${workflow.mcpTools.planBeforeEditing}\` with saveIntent=true.`,
|
|
198
|
+
`2. AFTER editing: MUST call \`${workflow.mcpTools.gateCurrentChange}\` or \`${workflow.mcpTools.checkChangedAgainstBase}\`.`,
|
|
199
|
+
"3. If mustStop=true or needsHuman=true: STOP and ask the human.",
|
|
200
|
+
"4. DO NOT edit `.ripple/` policy/cache/intent files unless explicitly requested.",
|
|
201
|
+
"5. DO NOT claim Ripple passed unless you called a Ripple MCP tool.",
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
const RIPPLE_AGENT_SECTION_START = "<!-- RIPPLE:START -->";
|
|
205
|
+
const RIPPLE_AGENT_SECTION_END = "<!-- RIPPLE:END -->";
|
|
206
|
+
function rippleAgentManagedSection(content) {
|
|
207
|
+
return [
|
|
208
|
+
RIPPLE_AGENT_SECTION_START,
|
|
209
|
+
content.trimEnd(),
|
|
210
|
+
RIPPLE_AGENT_SECTION_END,
|
|
211
|
+
"",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
function resolveAgentSetupFileNames(workspaceRoot) {
|
|
215
|
+
const existing = RIPPLE_AGENT_SETUP_FILE_NAMES.filter((fileName) => fs.existsSync(path.join(workspaceRoot, fileName)));
|
|
216
|
+
return existing.length > 0 ? existing : [RIPPLE_DEFAULT_AGENT_SETUP_FILE];
|
|
217
|
+
}
|
|
218
|
+
function agentSetupFiles(workspaceRoot) {
|
|
219
|
+
return resolveAgentSetupFileNames(workspaceRoot).map((fileName) => ({
|
|
220
|
+
path: fileName,
|
|
221
|
+
absolutePath: path.join(workspaceRoot, fileName),
|
|
222
|
+
content: rippleAgentManagedSection(agentInstructionMarkdown(workspaceRoot, fileName)),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
function buildAgentSetupSummary(workspaceRoot, files) {
|
|
226
|
+
const mcpArgs = ["-y", "@getripple/mcp", "--workspace", workspaceRoot];
|
|
227
|
+
return {
|
|
228
|
+
protocol: "ripple-agent-setup",
|
|
229
|
+
version: 1,
|
|
230
|
+
workspace: workspaceRoot,
|
|
231
|
+
files,
|
|
232
|
+
mcp: {
|
|
233
|
+
serverName: "ripple",
|
|
234
|
+
command: "npx",
|
|
235
|
+
args: mcpArgs,
|
|
236
|
+
workspace: workspaceRoot,
|
|
237
|
+
config: mcpServerConfig(workspaceRoot),
|
|
238
|
+
},
|
|
239
|
+
setupRequired: [
|
|
240
|
+
"Open your agent or IDE MCP settings.",
|
|
241
|
+
"Add a new MCP server named ripple.",
|
|
242
|
+
`Use command: npx ${mcpArgs.join(" ")}`,
|
|
243
|
+
"Restart or reload the agent so Ripple MCP tools become available.",
|
|
244
|
+
],
|
|
245
|
+
nextSteps: [
|
|
246
|
+
"Ask the agent to call ripple_get_agent_workflow to confirm MCP connectivity.",
|
|
247
|
+
"Before edits, the agent should call ripple_plan_context with saveIntent enabled.",
|
|
248
|
+
"After edits, the agent should call ripple_gate or ripple_check_changed before handoff.",
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function printAgentSetupSummary(summary) {
|
|
253
|
+
console.log("Ripple agent setup");
|
|
254
|
+
console.log(`Workspace: ${summary.workspace}`);
|
|
255
|
+
console.log("");
|
|
256
|
+
console.log("Generated files:");
|
|
257
|
+
summary.files.forEach((file) => {
|
|
258
|
+
console.log(` - ${file.path}: ${file.status}`);
|
|
259
|
+
});
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log("ACTION REQUIRED: connect Ripple MCP to your agent/IDE.");
|
|
262
|
+
console.log("");
|
|
263
|
+
console.log("MCP server:");
|
|
264
|
+
console.log(" name: ripple");
|
|
265
|
+
console.log(" command: npx");
|
|
266
|
+
console.log(` args: ${summary.mcp.args.join(" ")}`);
|
|
267
|
+
console.log("");
|
|
268
|
+
console.log("Paste this MCP config if your client accepts JSON:");
|
|
269
|
+
console.log(mcpServerConfigJson(summary.workspace));
|
|
270
|
+
console.log("");
|
|
271
|
+
console.log("Cursor / Claude / agent steps:");
|
|
272
|
+
summary.setupRequired.forEach((step, index) => console.log(` ${index + 1}. ${step}`));
|
|
273
|
+
console.log("");
|
|
274
|
+
console.log("Next:");
|
|
275
|
+
summary.nextSteps.forEach((step) => console.log(` - ${step}`));
|
|
276
|
+
}
|
|
277
|
+
function agentSetupCommand(options) {
|
|
278
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
279
|
+
const files = agentSetupFiles(workspaceRoot);
|
|
280
|
+
if (options.print) {
|
|
281
|
+
const summary = buildAgentSetupSummary(workspaceRoot, files.map((file) => ({
|
|
282
|
+
path: file.path,
|
|
283
|
+
status: "printed",
|
|
284
|
+
written: false,
|
|
285
|
+
overwritten: false,
|
|
286
|
+
content: file.content,
|
|
287
|
+
})));
|
|
288
|
+
if (options.json) {
|
|
289
|
+
printJson(summary);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
process.stdout.write(files
|
|
293
|
+
.flatMap((file) => [`# ${file.path}`, file.content.trimEnd(), ""])
|
|
294
|
+
.join("\n"));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const writtenFiles = files.map((file) => writeAgentSetupFile(file, options.force));
|
|
298
|
+
const summary = buildAgentSetupSummary(workspaceRoot, writtenFiles);
|
|
299
|
+
if (options.json) {
|
|
300
|
+
printJson(summary);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
printAgentSetupSummary(summary);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
161
306
|
function parseCliArgs(argv) {
|
|
162
307
|
let command;
|
|
163
308
|
const args = [];
|
|
@@ -168,6 +313,7 @@ function parseCliArgs(argv) {
|
|
|
168
313
|
budget: 4000,
|
|
169
314
|
staged: false,
|
|
170
315
|
changed: false,
|
|
316
|
+
worktree: false,
|
|
171
317
|
save: false,
|
|
172
318
|
strict: false,
|
|
173
319
|
githubAnnotations: false,
|
|
@@ -192,6 +338,10 @@ function parseCliArgs(argv) {
|
|
|
192
338
|
options.changed = true;
|
|
193
339
|
continue;
|
|
194
340
|
}
|
|
341
|
+
if (token === "--worktree") {
|
|
342
|
+
options.worktree = true;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
195
345
|
if (token === "--save") {
|
|
196
346
|
options.save = true;
|
|
197
347
|
continue;
|
|
@@ -290,6 +440,58 @@ function parseCliArgs(argv) {
|
|
|
290
440
|
options.gate = parseApprovalGate(token.slice("--gate=".length));
|
|
291
441
|
continue;
|
|
292
442
|
}
|
|
443
|
+
if (token === "--run") {
|
|
444
|
+
const value = argv[i + 1];
|
|
445
|
+
if (!value || value.startsWith("-")) {
|
|
446
|
+
throw new Error("Missing value for --run");
|
|
447
|
+
}
|
|
448
|
+
options.verificationRunCommand = value;
|
|
449
|
+
i++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (token.startsWith("--run=")) {
|
|
453
|
+
options.verificationRunCommand = token.slice("--run=".length);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (token === "--command") {
|
|
457
|
+
const value = argv[i + 1];
|
|
458
|
+
if (!value || value.startsWith("-")) {
|
|
459
|
+
throw new Error("Missing value for --command");
|
|
460
|
+
}
|
|
461
|
+
options.verificationCommand = value;
|
|
462
|
+
i++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (token.startsWith("--command=")) {
|
|
466
|
+
options.verificationCommand = token.slice("--command=".length);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
if (token === "--status") {
|
|
470
|
+
const value = argv[i + 1];
|
|
471
|
+
if (!value || value.startsWith("-")) {
|
|
472
|
+
throw new Error("Missing value for --status");
|
|
473
|
+
}
|
|
474
|
+
options.verificationStatus = parseVerificationStatus(value);
|
|
475
|
+
i++;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (token.startsWith("--status=")) {
|
|
479
|
+
options.verificationStatus = parseVerificationStatus(token.slice("--status=".length));
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (token === "--note") {
|
|
483
|
+
const value = argv[i + 1];
|
|
484
|
+
if (!value || value.startsWith("-")) {
|
|
485
|
+
throw new Error("Missing value for --note");
|
|
486
|
+
}
|
|
487
|
+
options.note = value;
|
|
488
|
+
i++;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (token.startsWith("--note=")) {
|
|
492
|
+
options.note = token.slice("--note=".length);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
293
495
|
if (token === "--reason") {
|
|
294
496
|
const value = argv[i + 1];
|
|
295
497
|
if (!value || value.startsWith("-")) {
|
|
@@ -342,6 +544,19 @@ function parseCliArgs(argv) {
|
|
|
342
544
|
options.base = token.slice("--base=".length);
|
|
343
545
|
continue;
|
|
344
546
|
}
|
|
547
|
+
if (token === "--sha") {
|
|
548
|
+
const value = argv[i + 1];
|
|
549
|
+
if (!value || value.startsWith("-")) {
|
|
550
|
+
throw new Error("Missing value for --sha");
|
|
551
|
+
}
|
|
552
|
+
options.sha = value;
|
|
553
|
+
i++;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (token.startsWith("--sha=")) {
|
|
557
|
+
options.sha = token.slice("--sha=".length);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
345
560
|
if (token === "--budget") {
|
|
346
561
|
const value = argv[i + 1];
|
|
347
562
|
if (!value || value.startsWith("-")) {
|
|
@@ -376,6 +591,12 @@ function parseControlMode(value) {
|
|
|
376
591
|
}
|
|
377
592
|
throw new Error(`--mode must be one of: ${CONTROL_MODES.join(", ")}`);
|
|
378
593
|
}
|
|
594
|
+
function parseVerificationStatus(value) {
|
|
595
|
+
if (value === "passed" || value === "failed" || value === "skipped" || value === "unknown") {
|
|
596
|
+
return value;
|
|
597
|
+
}
|
|
598
|
+
throw new Error("--status must be one of: passed, failed, skipped, unknown");
|
|
599
|
+
}
|
|
379
600
|
function parseApprovalGate(value) {
|
|
380
601
|
if (value === "before-risky-edit" || value === "before-merge") {
|
|
381
602
|
return value;
|
|
@@ -392,21 +613,48 @@ function version() {
|
|
|
392
613
|
return "0.0.0";
|
|
393
614
|
}
|
|
394
615
|
}
|
|
616
|
+
function rippleCliPackageSpec() {
|
|
617
|
+
const currentVersion = version();
|
|
618
|
+
return currentVersion === "0.0.0"
|
|
619
|
+
? "@getripple/cli"
|
|
620
|
+
: `@getripple/cli@${currentVersion}`;
|
|
621
|
+
}
|
|
622
|
+
function shellSingleQuote(value) {
|
|
623
|
+
return `'${value.replace(/'/g, `'\\''`).replace(/\\/g, "/")}'`;
|
|
624
|
+
}
|
|
625
|
+
function rippleDirectRunnerHookLines() {
|
|
626
|
+
return [
|
|
627
|
+
` ripple_direct_node=${shellSingleQuote(process.execPath)}`,
|
|
628
|
+
` ripple_direct_cli=${shellSingleQuote(__filename)}`,
|
|
629
|
+
` if [ -x "./node_modules/.bin/ripple" ]; then`,
|
|
630
|
+
` "./node_modules/.bin/ripple" "$@"`,
|
|
631
|
+
` elif [ -f "$ripple_direct_cli" ] && [ -f "$ripple_direct_node" ]; then`,
|
|
632
|
+
` "$ripple_direct_node" "$ripple_direct_cli" "$@"`,
|
|
633
|
+
` elif command -v ripple >/dev/null 2>&1; then`,
|
|
634
|
+
` ripple "$@"`,
|
|
635
|
+
` else`,
|
|
636
|
+
` npx -y ${rippleCliPackageSpec()} "$@"`,
|
|
637
|
+
` fi`,
|
|
638
|
+
];
|
|
639
|
+
}
|
|
395
640
|
const GITHUB_ACTIONS_WORKFLOW_PATH = core_1.RIPPLE_CI_WORKFLOW_PATH;
|
|
396
641
|
function githubActionsWorkflow() {
|
|
397
642
|
return [
|
|
398
|
-
"name: Ripple",
|
|
643
|
+
"name: Ripple Enterprise Gate",
|
|
399
644
|
"",
|
|
400
645
|
"on:",
|
|
401
646
|
" pull_request:",
|
|
647
|
+
" push:",
|
|
648
|
+
" branches: [main, master]",
|
|
402
649
|
"",
|
|
403
650
|
"permissions:",
|
|
404
651
|
" contents: read",
|
|
405
652
|
" pull-requests: read",
|
|
653
|
+
" checks: write",
|
|
406
654
|
"",
|
|
407
655
|
"jobs:",
|
|
408
656
|
" ripple:",
|
|
409
|
-
" name: Ripple
|
|
657
|
+
" name: Ripple authorization gate",
|
|
410
658
|
" runs-on: ubuntu-latest",
|
|
411
659
|
" steps:",
|
|
412
660
|
" - name: Checkout",
|
|
@@ -417,8 +665,11 @@ function githubActionsWorkflow() {
|
|
|
417
665
|
" uses: actions/setup-node@v4",
|
|
418
666
|
" with:",
|
|
419
667
|
" node-version: 20",
|
|
420
|
-
" - name: Ripple CI",
|
|
421
|
-
|
|
668
|
+
" - name: Ripple CI gate",
|
|
669
|
+
` run: npx -y ${rippleCliPackageSpec()} ci --base origin/\${{ github.base_ref }} --github-annotations --sha \${{ github.sha }}`,
|
|
670
|
+
" env:",
|
|
671
|
+
" RIPPLE_API_KEY: ${{ secrets.RIPPLE_API_KEY }}",
|
|
672
|
+
" RIPPLE_CLOUD_URL: ${{ secrets.RIPPLE_CLOUD_URL }}",
|
|
422
673
|
"",
|
|
423
674
|
].join("\n");
|
|
424
675
|
}
|
|
@@ -434,7 +685,7 @@ function defaultInitNextSteps(readiness) {
|
|
|
434
685
|
...(readiness?.nextSteps ?? []),
|
|
435
686
|
"Run ripple plan --file <file> --task \"<task>\" --mode file --agent --save.",
|
|
436
687
|
"Run ripple doctor --agent --strict after saving the first intent.",
|
|
437
|
-
"Commit .ripple/policy.json,
|
|
688
|
+
"Commit .ripple/policy.json, approvals when needed, and .github/workflows/ripple.yml. Keep local intents out of PRs.",
|
|
438
689
|
]);
|
|
439
690
|
}
|
|
440
691
|
function uniqueLines(lines) {
|
|
@@ -488,7 +739,7 @@ function intentLoadFailureMessage(intentRef, error) {
|
|
|
488
739
|
const detail = error instanceof Error ? error.message : String(error);
|
|
489
740
|
return [
|
|
490
741
|
`Could not load Ripple change intent '${intentRef}'.`,
|
|
491
|
-
"Run ripple plan --save
|
|
742
|
+
"Run ripple plan --save for local intent-based checks, or pass a valid --intent path. Do not commit local latest intents into PRs.",
|
|
492
743
|
detail,
|
|
493
744
|
].join(" ");
|
|
494
745
|
}
|
|
@@ -534,6 +785,149 @@ function writeGithubAuditStepSummary(audit) {
|
|
|
534
785
|
console.error(`Ripple CLI warning: Could not write GitHub step summary: ${message}`);
|
|
535
786
|
}
|
|
536
787
|
}
|
|
788
|
+
function writeGithubPolicyAuditStepSummary(summary, policySync) {
|
|
789
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY?.trim();
|
|
790
|
+
if (!summaryPath) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
fs.appendFileSync(summaryPath, buildGithubPolicyAuditStepSummary(summary, policySync), "utf8");
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
798
|
+
console.error(`Ripple CLI warning: Could not write GitHub step summary: ${message}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function pushMarkdownList(lines, title, items, limit) {
|
|
802
|
+
lines.push(`#### ${title}`);
|
|
803
|
+
if (items.length === 0) {
|
|
804
|
+
lines.push("- none");
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
items.slice(0, limit).forEach((item) => lines.push(`- ${item}`));
|
|
808
|
+
if (items.length > limit) {
|
|
809
|
+
lines.push(`- ...and ${items.length - limit} more`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function appendGithubReviewPacket(lines, packet) {
|
|
813
|
+
if (!packet) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
lines.push("### Review packet", "");
|
|
817
|
+
lines.push(`- Protocol: ${packet.protocol} v${packet.version}`);
|
|
818
|
+
lines.push(`- Task: ${packet.originalTask}`);
|
|
819
|
+
lines.push(`- Mode: ${packet.mode}`);
|
|
820
|
+
lines.push(`- Declared scope: ${packet.declaredScope.controlMode} ${packet.declaredScope.targetFile}`);
|
|
821
|
+
lines.push(`- Human gate: ${packet.declaredScope.humanGate}`);
|
|
822
|
+
lines.push(`- Boundary risk: ${packet.declaredScope.boundaryRisk}`);
|
|
823
|
+
lines.push(`- Tests run: ${packet.verification.testsRun}`);
|
|
824
|
+
if (packet.verification.evidence.length > 0) {
|
|
825
|
+
lines.push(`- Verification status: ${verificationEvidenceStatusLabel(packet.verification.evidence)}`);
|
|
826
|
+
}
|
|
827
|
+
lines.push(`- Decision: ${packet.decision.verdict}`);
|
|
828
|
+
lines.push(`- Can continue: ${packet.decision.canContinue}`);
|
|
829
|
+
lines.push(`- Must stop: ${packet.decision.mustStop}`);
|
|
830
|
+
lines.push(`- Needs human: ${packet.decision.needsHuman}`);
|
|
831
|
+
lines.push(`- Next required action: ${packet.decision.nextRequiredAction}`);
|
|
832
|
+
lines.push("");
|
|
833
|
+
pushMarkdownList(lines, "Allowed files", packet.declaredScope.allowedFiles, 12);
|
|
834
|
+
lines.push("");
|
|
835
|
+
pushMarkdownList(lines, "Allowed symbols", packet.declaredScope.allowedSymbols, 12);
|
|
836
|
+
lines.push("");
|
|
837
|
+
pushMarkdownList(lines, "Actual changed files", packet.actualChanges.changedFiles, 20);
|
|
838
|
+
lines.push("");
|
|
839
|
+
pushMarkdownList(lines, "Changed symbols", packet.actualChanges.changedSymbols, 16);
|
|
840
|
+
lines.push("");
|
|
841
|
+
pushMarkdownList(lines, "Outside boundary files", packet.scopeFindings.outsideBoundaryFiles, 20);
|
|
842
|
+
lines.push("");
|
|
843
|
+
pushMarkdownList(lines, "Outside boundary symbols", packet.scopeFindings.outsideBoundarySymbols, 16);
|
|
844
|
+
lines.push("");
|
|
845
|
+
pushMarkdownList(lines, "Contract changes to review", uniqueItems([
|
|
846
|
+
...packet.scopeFindings.protectedContractChanges,
|
|
847
|
+
...packet.scopeFindings.unplannedContractChanges,
|
|
848
|
+
]), 16);
|
|
849
|
+
lines.push("");
|
|
850
|
+
pushMarkdownList(lines, "Verification expected", packet.verification.expectedCommands, 20);
|
|
851
|
+
lines.push("");
|
|
852
|
+
pushMarkdownList(lines, "Verification evidence", packet.verification.evidence.map(formatVerificationEvidence), 20);
|
|
853
|
+
lines.push("");
|
|
854
|
+
lines.push("#### Verification note");
|
|
855
|
+
lines.push(`- ${packet.verification.note}`);
|
|
856
|
+
lines.push("");
|
|
857
|
+
pushMarkdownList(lines, "Reviewer notes", packet.reviewerNotes, 12);
|
|
858
|
+
lines.push("");
|
|
859
|
+
}
|
|
860
|
+
function buildGithubPolicyAuditStepSummary(summary, policySync) {
|
|
861
|
+
const pushList = (lines, title, items, limit) => {
|
|
862
|
+
lines.push(`#### ${title}`);
|
|
863
|
+
if (items.length === 0) {
|
|
864
|
+
lines.push("- none");
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
items.slice(0, limit).forEach((item) => lines.push(`- ${item}`));
|
|
868
|
+
if (items.length > limit) {
|
|
869
|
+
lines.push(`- ...and ${items.length - limit} more`);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
const lines = [
|
|
873
|
+
"## Ripple architecture gate",
|
|
874
|
+
"",
|
|
875
|
+
"Status: audit",
|
|
876
|
+
"Mode: policy-only",
|
|
877
|
+
"Blocking: false",
|
|
878
|
+
"Intent: none (local intents are not required in CI audit mode)",
|
|
879
|
+
`Checked files: ${summary.checkedFiles}`,
|
|
880
|
+
`Highest risk: ${summary.highestRisk}`,
|
|
881
|
+
`Requires attention: ${summary.requiresAttention}`,
|
|
882
|
+
"",
|
|
883
|
+
];
|
|
884
|
+
if (summary.baseRef) {
|
|
885
|
+
lines.splice(5, 0, `Base ref: ${summary.baseRef}`);
|
|
886
|
+
}
|
|
887
|
+
if (summary.files.length > 0) {
|
|
888
|
+
lines.push("### Changed files", "");
|
|
889
|
+
summary.files.slice(0, 20).forEach((file) => {
|
|
890
|
+
lines.push(`- ${file.file} (${file.modificationRisk}, importers: ${file.importerCount})`);
|
|
891
|
+
});
|
|
892
|
+
if (summary.files.length > 20) {
|
|
893
|
+
lines.push(`- ...and ${summary.files.length - 20} more`);
|
|
894
|
+
}
|
|
895
|
+
lines.push("");
|
|
896
|
+
}
|
|
897
|
+
if (policySync) {
|
|
898
|
+
lines.push("### Policy sync", "");
|
|
899
|
+
lines.push(`Status: ${policySync.status}`);
|
|
900
|
+
if (policySync.missingRules.length > 0) {
|
|
901
|
+
policySync.missingRules.slice(0, 12).forEach((rule) => {
|
|
902
|
+
lines.push(`- ${rule.paths.join(", ")} (risk: ${rule.risk ?? "medium"})`);
|
|
903
|
+
});
|
|
904
|
+
if (policySync.missingRules.length > 12) {
|
|
905
|
+
lines.push(`- ...and ${policySync.missingRules.length - 12} more`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
lines.push("- up to date");
|
|
910
|
+
}
|
|
911
|
+
lines.push("");
|
|
912
|
+
}
|
|
913
|
+
lines.push("### Agent actions", "");
|
|
914
|
+
pushList(lines, "Trusted findings", summary.agentActions.trustedFindings, 12);
|
|
915
|
+
lines.push("");
|
|
916
|
+
pushList(lines, "Verify before merge", summary.agentActions.verifyBeforeCommit, 12);
|
|
917
|
+
lines.push("");
|
|
918
|
+
pushList(lines, "Manual review recommended", summary.agentActions.manualReviewRequired, 12);
|
|
919
|
+
lines.push("");
|
|
920
|
+
const verificationTargets = uniqueItems(summary.files.flatMap((file) => file.verificationTargets));
|
|
921
|
+
if (verificationTargets.length > 0) {
|
|
922
|
+
lines.push("### Verify", "");
|
|
923
|
+
verificationTargets.slice(0, 20).forEach((target) => lines.push(`- ${target}`));
|
|
924
|
+
if (verificationTargets.length > 20) {
|
|
925
|
+
lines.push(`- ...and ${verificationTargets.length - 20} more`);
|
|
926
|
+
}
|
|
927
|
+
lines.push("");
|
|
928
|
+
}
|
|
929
|
+
return `${lines.join("\n")}\n`;
|
|
930
|
+
}
|
|
537
931
|
function buildGithubStepSummary(input) {
|
|
538
932
|
const { summary, intentLoadError } = input;
|
|
539
933
|
const validation = summary.intentValidation;
|
|
@@ -588,6 +982,7 @@ function buildGithubStepSummary(input) {
|
|
|
588
982
|
else {
|
|
589
983
|
lines.push("### Intent", "", "- No saved change intent was provided.", `- Next required phase: ${nextRequiredPhase}`, `- Next required action: ${nextRequiredAction}`, "");
|
|
590
984
|
}
|
|
985
|
+
appendGithubReviewPacket(lines, summary.reviewPacket);
|
|
591
986
|
const blockingReasons = validation?.blockingReasons ?? [];
|
|
592
987
|
if (blockingReasons.length > 0) {
|
|
593
988
|
lines.push("### Blocking reasons", "");
|
|
@@ -681,6 +1076,7 @@ function buildGithubAuditStepSummary(audit) {
|
|
|
681
1076
|
}
|
|
682
1077
|
pushList(lines, "Approval why", audit.approvalStatus.why, 8);
|
|
683
1078
|
lines.push("");
|
|
1079
|
+
appendGithubReviewPacket(lines, audit.reviewPacket);
|
|
684
1080
|
if (audit.blockingReasons.length > 0) {
|
|
685
1081
|
lines.push("### Blocking reasons", "");
|
|
686
1082
|
audit.blockingReasons.forEach((reason) => lines.push(`- ${reason}`));
|
|
@@ -763,13 +1159,13 @@ function printGithubCheckAnnotations(summary) {
|
|
|
763
1159
|
return;
|
|
764
1160
|
}
|
|
765
1161
|
if (validation.policyDrift.status === "changed") {
|
|
766
|
-
|
|
1162
|
+
printGithubWarningAnnotation({
|
|
767
1163
|
file: validation.targetFile,
|
|
768
1164
|
title: "Ripple policy drift",
|
|
769
1165
|
message: validation.policyDrift.summary,
|
|
770
1166
|
});
|
|
771
1167
|
validation.policyDrift.changedFields.slice(0, 8).forEach((field) => {
|
|
772
|
-
|
|
1168
|
+
printGithubWarningAnnotation({
|
|
773
1169
|
file: validation.targetFile,
|
|
774
1170
|
title: "Ripple policy drift",
|
|
775
1171
|
message: field,
|
|
@@ -777,13 +1173,13 @@ function printGithubCheckAnnotations(summary) {
|
|
|
777
1173
|
});
|
|
778
1174
|
}
|
|
779
1175
|
if (validation.readinessDrift.status === "weakened") {
|
|
780
|
-
|
|
1176
|
+
printGithubWarningAnnotation({
|
|
781
1177
|
file: validation.targetFile,
|
|
782
1178
|
title: "Ripple readiness drift",
|
|
783
1179
|
message: validation.readinessDrift.summary,
|
|
784
1180
|
});
|
|
785
1181
|
validation.readinessDrift.weakenedFields.slice(0, 8).forEach((field) => {
|
|
786
|
-
|
|
1182
|
+
printGithubWarningAnnotation({
|
|
787
1183
|
file: validation.targetFile,
|
|
788
1184
|
title: "Ripple readiness drift",
|
|
789
1185
|
message: `Weakened readiness field: ${field}`,
|
|
@@ -791,7 +1187,7 @@ function printGithubCheckAnnotations(summary) {
|
|
|
791
1187
|
});
|
|
792
1188
|
}
|
|
793
1189
|
validation.unplannedFiles.forEach((file) => {
|
|
794
|
-
|
|
1190
|
+
printGithubWarningAnnotation({
|
|
795
1191
|
file,
|
|
796
1192
|
title: "Ripple intent drift",
|
|
797
1193
|
message: `Unplanned file changed: ${file}`,
|
|
@@ -799,7 +1195,7 @@ function printGithubCheckAnnotations(summary) {
|
|
|
799
1195
|
});
|
|
800
1196
|
validation.unplannedSymbols.forEach((symbol) => {
|
|
801
1197
|
const file = symbolFile(symbol);
|
|
802
|
-
|
|
1198
|
+
printGithubWarningAnnotation({
|
|
803
1199
|
file,
|
|
804
1200
|
title: "Ripple symbol drift",
|
|
805
1201
|
message: `Unplanned symbol changed: ${symbol}`,
|
|
@@ -853,24 +1249,58 @@ function printGithubCheckAnnotations(summary) {
|
|
|
853
1249
|
});
|
|
854
1250
|
});
|
|
855
1251
|
summary.agentActions.manualReviewRequired.slice(0, 12).forEach((action) => {
|
|
856
|
-
|
|
1252
|
+
printGithubWarningAnnotation({
|
|
857
1253
|
file: actionFile(action),
|
|
858
1254
|
title: "Ripple manual review required",
|
|
859
1255
|
message: action,
|
|
860
1256
|
});
|
|
861
1257
|
});
|
|
862
1258
|
}
|
|
1259
|
+
function printGithubPolicyAuditAnnotations(summary, policySync) {
|
|
1260
|
+
if (summary.requiresAttention) {
|
|
1261
|
+
printGithubWarningAnnotation({
|
|
1262
|
+
title: "Ripple policy audit",
|
|
1263
|
+
message: `Policy audit detected ${summary.highestRisk} risk changes. Ripple is in audit mode, so this does not block merge. Ensure human review before merging.`,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
printGithubNoticeAnnotation({
|
|
1268
|
+
title: "Ripple policy audit",
|
|
1269
|
+
message: "Policy audit completed without high-risk findings.",
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
if (policySync && policySync.missingRules.length > 0) {
|
|
1273
|
+
printGithubWarningAnnotation({
|
|
1274
|
+
title: "Ripple policy rot",
|
|
1275
|
+
message: `Policy may be missing ${policySync.missingRules.length} risky repo surface(s). Run ripple policy sync and review .ripple/policy.json.`,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
summary.agentActions.verifyBeforeCommit.slice(0, 12).forEach((action) => {
|
|
1279
|
+
printGithubWarningAnnotation({
|
|
1280
|
+
file: actionFile(action),
|
|
1281
|
+
title: "Ripple verify before merge",
|
|
1282
|
+
message: action,
|
|
1283
|
+
});
|
|
1284
|
+
});
|
|
1285
|
+
summary.agentActions.manualReviewRequired.slice(0, 12).forEach((action) => {
|
|
1286
|
+
printGithubWarningAnnotation({
|
|
1287
|
+
file: actionFile(action),
|
|
1288
|
+
title: "Ripple manual review recommended",
|
|
1289
|
+
message: action,
|
|
1290
|
+
});
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
863
1293
|
function printGithubAuditAnnotations(audit) {
|
|
864
1294
|
const gate = (0, core_1.buildRippleGateSummary)(audit);
|
|
865
1295
|
if (audit.status !== "pass") {
|
|
866
|
-
|
|
1296
|
+
printGithubWarningAnnotation({
|
|
867
1297
|
file: audit.intent.targetFile,
|
|
868
1298
|
title: "Ripple gate closed",
|
|
869
1299
|
message: `${gate.status}/${gate.decision}: next=${gate.nextRequiredPhase}. ${gate.nextRequiredAction}`,
|
|
870
1300
|
});
|
|
871
1301
|
}
|
|
872
1302
|
if (audit.approvalStatus.required && !audit.approvalStatus.approved) {
|
|
873
|
-
|
|
1303
|
+
printGithubWarningAnnotation({
|
|
874
1304
|
file: audit.intent.targetFile,
|
|
875
1305
|
title: "Ripple approval required",
|
|
876
1306
|
message: audit.approvalStatus.summary,
|
|
@@ -930,6 +1360,28 @@ function relativeToWorkspace(workspaceRoot, filePath) {
|
|
|
930
1360
|
function normalizeProjectPath(filePath) {
|
|
931
1361
|
return filePath.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
932
1362
|
}
|
|
1363
|
+
function resolveCliIntentPath(workspaceRoot, intentRef) {
|
|
1364
|
+
const normalized = intentRef.trim();
|
|
1365
|
+
if (normalized.length === 0 || normalized === "latest") {
|
|
1366
|
+
return (0, core_1.defaultChangeIntentPath)(workspaceRoot);
|
|
1367
|
+
}
|
|
1368
|
+
if (path.isAbsolute(normalized)) {
|
|
1369
|
+
return normalized;
|
|
1370
|
+
}
|
|
1371
|
+
if (normalized.endsWith(".json") || normalized.includes("/") || normalized.includes("\\")) {
|
|
1372
|
+
return path.resolve(workspaceRoot, normalized);
|
|
1373
|
+
}
|
|
1374
|
+
return path.join(workspaceRoot, ".ripple", "intents", `${normalized}.json`);
|
|
1375
|
+
}
|
|
1376
|
+
function isActiveChangeIntentFile(filePath) {
|
|
1377
|
+
try {
|
|
1378
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
1379
|
+
return parsed.protocol === "ripple-change-intent";
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
933
1385
|
function formatEventLine(event) {
|
|
934
1386
|
const target = event.target ? ` -> ${event.target}` : "";
|
|
935
1387
|
const details = [
|
|
@@ -1021,6 +1473,15 @@ function printDoctorSummary(summary) {
|
|
|
1021
1473
|
summary.enforcement.gaps.forEach((gap) => console.log(` - ${gap}`));
|
|
1022
1474
|
}
|
|
1023
1475
|
console.log("");
|
|
1476
|
+
console.log("Policy sync:");
|
|
1477
|
+
console.log(` status: ${summary.policySync.status}`);
|
|
1478
|
+
if (summary.policySync.missingRules.length > 0) {
|
|
1479
|
+
console.log(" missing coverage:");
|
|
1480
|
+
summary.policySync.missingRules.slice(0, 12).forEach((rule) => {
|
|
1481
|
+
console.log(` - ${rule.paths.join(", ")} risk=${rule.risk ?? "medium"}`);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
console.log("");
|
|
1024
1485
|
console.log("Next steps:");
|
|
1025
1486
|
summary.nextSteps.forEach((step) => console.log(` - ${step}`));
|
|
1026
1487
|
}
|
|
@@ -1032,6 +1493,21 @@ function printInitSummary(summary) {
|
|
|
1032
1493
|
summary.files.forEach((file) => {
|
|
1033
1494
|
console.log(` - ${file.path}: ${file.status}`);
|
|
1034
1495
|
});
|
|
1496
|
+
if (summary.agentSetup) {
|
|
1497
|
+
console.log("");
|
|
1498
|
+
console.log("Agent setup files:");
|
|
1499
|
+
summary.agentSetup.files.forEach((file) => {
|
|
1500
|
+
console.log(` - ${file.path}: ${file.status}`);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
if (summary.hooks) {
|
|
1504
|
+
console.log("");
|
|
1505
|
+
console.log("Git hooks:");
|
|
1506
|
+
console.log(` - ${summary.hooks.path}: ${summary.hooks.preCommitAction ?? summary.hooks.status}`);
|
|
1507
|
+
if (summary.hooks.postCommitPath) {
|
|
1508
|
+
console.log(` - ${summary.hooks.postCommitPath}: ${summary.hooks.postCommitAction ?? summary.hooks.status}`);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1035
1511
|
if (summary.readiness) {
|
|
1036
1512
|
console.log("");
|
|
1037
1513
|
console.log("Readiness after init:");
|
|
@@ -1067,6 +1543,13 @@ function printAgentDoctorSummary(summary) {
|
|
|
1067
1543
|
console.log(`git_ignore: ${summary.checks.gitIgnore.ok ? "ok" : "missing"} - ${summary.checks.gitIgnore.detail}`);
|
|
1068
1544
|
console.log(`ci_workflow: ${summary.checks.ciWorkflow.ok ? "ok" : "missing"} - ${summary.checks.ciWorkflow.detail}`);
|
|
1069
1545
|
console.log(`latest_intent: ${summary.checks.latestIntent.ok ? "ok" : "missing"} - ${summary.checks.latestIntent.detail}`);
|
|
1546
|
+
console.log(`policy_sync: ${summary.policySync.status}`);
|
|
1547
|
+
if (summary.policySync.missingRules.length > 0) {
|
|
1548
|
+
console.log("policy_sync_missing_rules:");
|
|
1549
|
+
summary.policySync.missingRules.slice(0, 12).forEach((rule) => {
|
|
1550
|
+
console.log(`- ${rule.paths.join(", ")} risk=${rule.risk ?? "medium"}`);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1070
1553
|
console.log("");
|
|
1071
1554
|
printAgentList("why", summary.why);
|
|
1072
1555
|
console.log("");
|
|
@@ -1492,6 +1975,10 @@ function printAgentStagedCheckSummary(summary) {
|
|
|
1492
1975
|
console.log(`checked_js_ts_files: ${summary.stagedFiles}`);
|
|
1493
1976
|
console.log(`checked_files: ${summary.checkedFiles}`);
|
|
1494
1977
|
printAgentIntentValidation(summary.intentValidation);
|
|
1978
|
+
if (summary.reviewPacket) {
|
|
1979
|
+
console.log("");
|
|
1980
|
+
printAgentReviewPacket(summary.reviewPacket);
|
|
1981
|
+
}
|
|
1495
1982
|
console.log("");
|
|
1496
1983
|
printAgentList("trusted_findings", summary.agentActions.trustedFindings);
|
|
1497
1984
|
console.log("");
|
|
@@ -1760,6 +2247,8 @@ function printAgentAuditSummary(summary) {
|
|
|
1760
2247
|
console.log(`repair_status: ${summary.repairPlan.status}`);
|
|
1761
2248
|
console.log(`recommended_action: ${summary.recommendedAction}`);
|
|
1762
2249
|
console.log("");
|
|
2250
|
+
printAgentReviewPacket(summary.reviewPacket);
|
|
2251
|
+
console.log("");
|
|
1763
2252
|
printAgentHandoffBlock("handoff", summary.handoff);
|
|
1764
2253
|
if (summary.approvalStatus.approval) {
|
|
1765
2254
|
console.log(`approved_by: ${summary.approvalStatus.approval.approvedBy}`);
|
|
@@ -1817,6 +2306,8 @@ function printAgentGateSummary(summary) {
|
|
|
1817
2306
|
console.log(`risk_score: ${summary.risk.score}`);
|
|
1818
2307
|
console.log(`risk_summary: ${summary.risk.summary}`);
|
|
1819
2308
|
console.log("");
|
|
2309
|
+
printAgentReviewPacket(summary.reviewPacket);
|
|
2310
|
+
console.log("");
|
|
1820
2311
|
printAgentList("risk_reasons", compactGateRiskReasons(summary));
|
|
1821
2312
|
console.log("");
|
|
1822
2313
|
printAgentList("risk_evidence", compactGateRiskEvidence(summary));
|
|
@@ -1845,15 +2336,43 @@ function printAgentGateSummary(summary) {
|
|
|
1845
2336
|
console.log("");
|
|
1846
2337
|
printAgentList("commands_verify", summary.commands.verify);
|
|
1847
2338
|
}
|
|
1848
|
-
function
|
|
1849
|
-
|
|
1850
|
-
console.log(`
|
|
1851
|
-
console.log(
|
|
1852
|
-
console.log(
|
|
1853
|
-
console.log(`
|
|
1854
|
-
console.log(`
|
|
1855
|
-
console.log(`
|
|
1856
|
-
|
|
2339
|
+
function printAgentGateIntentBlock(summary) {
|
|
2340
|
+
console.log("RIPPLE_GATE_INTENT_BLOCK");
|
|
2341
|
+
console.log(`protocol: ${summary.protocol}`);
|
|
2342
|
+
console.log(`status: ${summary.status}`);
|
|
2343
|
+
console.log(`decision: ${summary.decision}`);
|
|
2344
|
+
console.log(`can_continue: ${summary.canContinue}`);
|
|
2345
|
+
console.log(`must_stop: ${summary.mustStop}`);
|
|
2346
|
+
console.log(`needs_human: ${summary.needsHuman}`);
|
|
2347
|
+
console.log(`next_required_phase: ${summary.nextRequiredPhase}`);
|
|
2348
|
+
console.log(`next_required_action: ${summary.nextRequiredAction}`);
|
|
2349
|
+
console.log(`summary: ${summary.summary}`);
|
|
2350
|
+
console.log(`mode: ${summary.mode}`);
|
|
2351
|
+
if (summary.baseRef) {
|
|
2352
|
+
console.log(`base_ref: ${summary.baseRef}`);
|
|
2353
|
+
}
|
|
2354
|
+
console.log(`intent_ref: ${summary.intentRef}`);
|
|
2355
|
+
console.log(`intent_state: ${summary.intentState}`);
|
|
2356
|
+
console.log(`intent_error: ${summary.intentLoadError}`);
|
|
2357
|
+
console.log("");
|
|
2358
|
+
printAgentList("why", summary.why);
|
|
2359
|
+
console.log("");
|
|
2360
|
+
printAgentList("fix_now", summary.fixNow);
|
|
2361
|
+
console.log("");
|
|
2362
|
+
printAgentList("ask_human", summary.askHuman);
|
|
2363
|
+
console.log("");
|
|
2364
|
+
printAgentList("commands_plan", summary.commands.plan);
|
|
2365
|
+
}
|
|
2366
|
+
function printGateSummary(summary) {
|
|
2367
|
+
const statusLabel = summary.canContinue ? "CONTINUE" : "STOP";
|
|
2368
|
+
console.log(`Ripple gate: ${statusLabel}`);
|
|
2369
|
+
console.log(gateHeadline(summary));
|
|
2370
|
+
console.log("");
|
|
2371
|
+
console.log(`Decision: ${summary.decision}`);
|
|
2372
|
+
console.log(`Can continue: ${formatYesNo(summary.canContinue)}`);
|
|
2373
|
+
console.log(`Must stop: ${formatYesNo(summary.mustStop)}`);
|
|
2374
|
+
console.log("");
|
|
2375
|
+
printReviewPacketSummary(summary.reviewPacket);
|
|
1857
2376
|
console.log("");
|
|
1858
2377
|
console.log("Intent:");
|
|
1859
2378
|
console.log(` Task: ${summary.intent.task}`);
|
|
@@ -1865,6 +2384,7 @@ function printGateSummary(summary) {
|
|
|
1865
2384
|
printHumanList("Allowed:", gateAllowedItems(summary));
|
|
1866
2385
|
const outsideBoundary = gateChangedOutsideItems(summary);
|
|
1867
2386
|
printHumanList(outsideBoundary.length > 0 ? "Changed outside boundary:" : "Changed files:", outsideBoundary.length > 0 ? outsideBoundary : summary.changedFiles);
|
|
2387
|
+
console.log("");
|
|
1868
2388
|
printHumanList("Why:", compactGateReasons(summary));
|
|
1869
2389
|
printHumanList("Fix now:", compactGateFixes(summary));
|
|
1870
2390
|
if (summary.canContinue) {
|
|
@@ -1873,6 +2393,28 @@ function printGateSummary(summary) {
|
|
|
1873
2393
|
else {
|
|
1874
2394
|
printHumanList("Commands:", compactGateCommands(summary));
|
|
1875
2395
|
}
|
|
2396
|
+
printGateRiskSummary(summary);
|
|
2397
|
+
}
|
|
2398
|
+
function printGateIntentBlock(summary) {
|
|
2399
|
+
console.log("Ripple gate: STOP");
|
|
2400
|
+
console.log("Agent must stop before continuing.");
|
|
2401
|
+
console.log("");
|
|
2402
|
+
console.log(`Decision: ${summary.decision}`);
|
|
2403
|
+
console.log(`Can continue: ${formatYesNo(summary.canContinue)}`);
|
|
2404
|
+
console.log(`Must stop: ${formatYesNo(summary.mustStop)}`);
|
|
2405
|
+
console.log(`Needs human: ${formatYesNo(summary.needsHuman)}`);
|
|
2406
|
+
console.log(`Next required phase: ${summary.nextRequiredPhase}`);
|
|
2407
|
+
console.log(`Next required action: ${summary.nextRequiredAction}`);
|
|
2408
|
+
console.log("");
|
|
2409
|
+
console.log("Intent:");
|
|
2410
|
+
console.log(` Ref: ${summary.intentRef}`);
|
|
2411
|
+
console.log(` State: ${summary.intentState}`);
|
|
2412
|
+
console.log(` Error: ${summary.intentLoadError}`);
|
|
2413
|
+
console.log("");
|
|
2414
|
+
printHumanList("Why:", summary.why);
|
|
2415
|
+
printHumanList("Fix now:", summary.fixNow);
|
|
2416
|
+
printHumanList("Ask human:", summary.askHuman);
|
|
2417
|
+
printHumanList("Commands:", summary.commands.plan);
|
|
1876
2418
|
}
|
|
1877
2419
|
function printGateRiskSummary(summary) {
|
|
1878
2420
|
console.log("");
|
|
@@ -1967,6 +2509,8 @@ function printAuditSummary(summary) {
|
|
|
1967
2509
|
console.log(` next required action: ${gate.nextRequiredAction}`);
|
|
1968
2510
|
console.log(` summary: ${gate.summary}`);
|
|
1969
2511
|
console.log("");
|
|
2512
|
+
printReviewPacketSummary(summary.reviewPacket);
|
|
2513
|
+
console.log("");
|
|
1970
2514
|
console.log("Approval:");
|
|
1971
2515
|
console.log(` status: ${summary.approvalStatus.status}`);
|
|
1972
2516
|
console.log(` decision: ${summary.approvalStatus.decision}`);
|
|
@@ -2125,9 +2669,83 @@ function printHumanList(title, items) {
|
|
|
2125
2669
|
}
|
|
2126
2670
|
items.forEach((item) => console.log(` - ${item}`));
|
|
2127
2671
|
}
|
|
2672
|
+
function formatVerificationEvidence(evidence) {
|
|
2673
|
+
const note = evidence.note ? ` note=${evidence.note}` : "";
|
|
2674
|
+
const exitCode = typeof evidence.exitCode === "number" ? ` exitCode=${evidence.exitCode}` : "";
|
|
2675
|
+
const duration = typeof evidence.durationMs === "number" ? ` durationMs=${evidence.durationMs}` : "";
|
|
2676
|
+
const files = evidence.changedFiles
|
|
2677
|
+
? ` files=${evidence.changedFiles.length > 0 ? evidence.changedFiles.join(",") : "none"}`
|
|
2678
|
+
: "";
|
|
2679
|
+
const mode = evidence.changeMode ? ` mode=${evidence.changeMode}` : "";
|
|
2680
|
+
const fingerprint = evidence.changeFingerprint
|
|
2681
|
+
? ` fingerprint=${evidence.changeFingerprint.slice(0, 12)}`
|
|
2682
|
+
: "";
|
|
2683
|
+
return `${evidence.status}: ${evidence.command} (${evidence.source}${exitCode}${duration}${files}${mode}${fingerprint} ${evidence.recordedAt})${note}`;
|
|
2684
|
+
}
|
|
2685
|
+
function verificationEvidenceStatusLabel(evidence) {
|
|
2686
|
+
if (evidence.length === 0) {
|
|
2687
|
+
return "none";
|
|
2688
|
+
}
|
|
2689
|
+
if (evidence.some((item) => item.status === "failed")) {
|
|
2690
|
+
return "failed";
|
|
2691
|
+
}
|
|
2692
|
+
if (evidence.some((item) => item.status === "unknown")) {
|
|
2693
|
+
return "unknown";
|
|
2694
|
+
}
|
|
2695
|
+
if (evidence.some((item) => item.status === "skipped")) {
|
|
2696
|
+
return "skipped";
|
|
2697
|
+
}
|
|
2698
|
+
return "passed";
|
|
2699
|
+
}
|
|
2700
|
+
function printReviewPacketSummary(packet) {
|
|
2701
|
+
console.log("Review packet:");
|
|
2702
|
+
console.log(` protocol: ${packet.protocol}`);
|
|
2703
|
+
console.log(` task: ${packet.originalTask}`);
|
|
2704
|
+
console.log(` declared scope: ${packet.declaredScope.controlMode} ${packet.declaredScope.targetFile}`);
|
|
2705
|
+
console.log(` human gate: ${packet.declaredScope.humanGate}`);
|
|
2706
|
+
console.log(` boundary risk: ${packet.declaredScope.boundaryRisk}`);
|
|
2707
|
+
printHumanList(" changed files", packet.actualChanges.changedFiles);
|
|
2708
|
+
printHumanList(" outside boundary files", packet.scopeFindings.outsideBoundaryFiles);
|
|
2709
|
+
printHumanList(" outside boundary symbols", packet.scopeFindings.outsideBoundarySymbols);
|
|
2710
|
+
printHumanList(" verification expected", packet.verification.expectedCommands);
|
|
2711
|
+
console.log(` tests run: ${packet.verification.testsRun}`);
|
|
2712
|
+
if (packet.verification.evidence.length > 0) {
|
|
2713
|
+
console.log(` verification status: ${verificationEvidenceStatusLabel(packet.verification.evidence)}`);
|
|
2714
|
+
}
|
|
2715
|
+
printHumanList(" verification evidence", packet.verification.evidence.map(formatVerificationEvidence));
|
|
2716
|
+
console.log(` can continue: ${formatYesNo(packet.decision.canContinue)}`);
|
|
2717
|
+
console.log(` must stop: ${formatYesNo(packet.decision.mustStop)}`);
|
|
2718
|
+
console.log(` needs human: ${formatYesNo(packet.decision.needsHuman)}`);
|
|
2719
|
+
printHumanList(" reviewer notes", packet.reviewerNotes);
|
|
2720
|
+
}
|
|
2721
|
+
function printAgentReviewPacket(packet) {
|
|
2722
|
+
console.log(`review_packet_protocol: ${packet.protocol}`);
|
|
2723
|
+
console.log(`review_packet_version: ${packet.version}`);
|
|
2724
|
+
console.log(`review_packet_task: ${packet.originalTask}`);
|
|
2725
|
+
console.log(`review_packet_scope: ${packet.declaredScope.controlMode} ${packet.declaredScope.targetFile}`);
|
|
2726
|
+
console.log(`review_packet_human_gate: ${packet.declaredScope.humanGate}`);
|
|
2727
|
+
console.log(`review_packet_boundary_risk: ${packet.declaredScope.boundaryRisk}`);
|
|
2728
|
+
console.log(`review_packet_tests_run: ${packet.verification.testsRun}`);
|
|
2729
|
+
console.log(`review_packet_verification_status: ${verificationEvidenceStatusLabel(packet.verification.evidence)}`);
|
|
2730
|
+
console.log(`review_packet_can_continue: ${packet.decision.canContinue}`);
|
|
2731
|
+
console.log(`review_packet_must_stop: ${packet.decision.mustStop}`);
|
|
2732
|
+
console.log(`review_packet_needs_human: ${packet.decision.needsHuman}`);
|
|
2733
|
+
console.log("");
|
|
2734
|
+
printAgentList("review_packet_changed_files", packet.actualChanges.changedFiles);
|
|
2735
|
+
console.log("");
|
|
2736
|
+
printAgentList("review_packet_outside_boundary_files", packet.scopeFindings.outsideBoundaryFiles);
|
|
2737
|
+
console.log("");
|
|
2738
|
+
printAgentList("review_packet_outside_boundary_symbols", packet.scopeFindings.outsideBoundarySymbols);
|
|
2739
|
+
console.log("");
|
|
2740
|
+
printAgentList("review_packet_verification_expected", packet.verification.expectedCommands);
|
|
2741
|
+
console.log("");
|
|
2742
|
+
printAgentList("review_packet_verification_reported", packet.verification.evidence.map(formatVerificationEvidence));
|
|
2743
|
+
console.log("");
|
|
2744
|
+
printAgentList("review_packet_reviewer_notes", packet.reviewerNotes);
|
|
2745
|
+
}
|
|
2128
2746
|
function printStagedCheckSummary(summary) {
|
|
2129
2747
|
const adapter = summary.adapterSupport.primaryAdapter;
|
|
2130
|
-
console.log(summary.mode === "changed" ? "Ripple changed-files check" : "Ripple staged check");
|
|
2748
|
+
console.log(summary.mode === "changed" ? "Ripple changed-files check" : summary.mode === "worktree" ? "Ripple worktree check" : "Ripple staged check");
|
|
2131
2749
|
console.log(`Workspace: ${summary.workspace}`);
|
|
2132
2750
|
console.log(`Mode: ${summary.mode}`);
|
|
2133
2751
|
if (summary.baseRef) {
|
|
@@ -2151,6 +2769,10 @@ function printStagedCheckSummary(summary) {
|
|
|
2151
2769
|
printReadinessDriftSummary("Readiness drift:", summary.intentValidation.readinessDrift);
|
|
2152
2770
|
console.log(`Planned scope: ${summary.intentValidation.plannedScope}`);
|
|
2153
2771
|
}
|
|
2772
|
+
if (summary.reviewPacket) {
|
|
2773
|
+
console.log("");
|
|
2774
|
+
printReviewPacketSummary(summary.reviewPacket);
|
|
2775
|
+
}
|
|
2154
2776
|
if (summary.skippedFiles.length > 0) {
|
|
2155
2777
|
console.log(`Skipped non-source files: ${summary.skippedFiles.length}`);
|
|
2156
2778
|
}
|
|
@@ -2163,7 +2785,9 @@ function printStagedCheckSummary(summary) {
|
|
|
2163
2785
|
console.log("");
|
|
2164
2786
|
console.log(summary.mode === "changed"
|
|
2165
2787
|
? "No changed JS/TS files found."
|
|
2166
|
-
:
|
|
2788
|
+
: summary.mode === "worktree"
|
|
2789
|
+
? "No worktree JS/TS changes found."
|
|
2790
|
+
: "No staged JS/TS files found.");
|
|
2167
2791
|
return;
|
|
2168
2792
|
}
|
|
2169
2793
|
console.log("");
|
|
@@ -2374,7 +2998,7 @@ async function planCommand(options) {
|
|
|
2374
2998
|
const output = savedIntent
|
|
2375
2999
|
? {
|
|
2376
3000
|
...summary,
|
|
2377
|
-
policyExplanation,
|
|
3001
|
+
policyExplanation: savedIntent.intent.policyExplanation,
|
|
2378
3002
|
changeIntent: savedIntent.intent,
|
|
2379
3003
|
changeIntentPath: savedIntent.path,
|
|
2380
3004
|
}
|
|
@@ -2419,6 +3043,140 @@ function currentPolicyExplanationForIntent(workspaceRoot, intent) {
|
|
|
2419
3043
|
function currentReadinessSnapshotForEngine(workspaceRoot, engine) {
|
|
2420
3044
|
return (0, core_1.buildChangeIntentReadinessSnapshot)((0, core_1.buildRippleReadinessSummary)(workspaceRoot, engine));
|
|
2421
3045
|
}
|
|
3046
|
+
function intentSnapshot(intent) {
|
|
3047
|
+
return {
|
|
3048
|
+
id: intent.id,
|
|
3049
|
+
createdAt: intent.createdAt,
|
|
3050
|
+
task: intent.task,
|
|
3051
|
+
targetFile: intent.targetFile,
|
|
3052
|
+
controlMode: intent.controlMode,
|
|
3053
|
+
humanGate: intent.humanGate,
|
|
3054
|
+
boundaryRisk: intent.boundaryRisk,
|
|
3055
|
+
};
|
|
3056
|
+
}
|
|
3057
|
+
function intentCommand(action, options) {
|
|
3058
|
+
if (!action || action === "status") {
|
|
3059
|
+
intentStatusCommand(options);
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
if (action === "close") {
|
|
3063
|
+
closeIntentCommand(options);
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
throw new Error("Usage: ripple intent status [--intent latest|path] or ripple intent close --reason <text> [--intent latest|path]");
|
|
3067
|
+
}
|
|
3068
|
+
function intentStatusCommand(options) {
|
|
3069
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
3070
|
+
const intentRef = options.intent ?? "latest";
|
|
3071
|
+
const intentPath = resolveCliIntentPath(workspaceRoot, intentRef);
|
|
3072
|
+
const exists = fs.existsSync(intentPath);
|
|
3073
|
+
const active = exists && isActiveChangeIntentFile(intentPath);
|
|
3074
|
+
const output = {
|
|
3075
|
+
protocol: "ripple-intent-status",
|
|
3076
|
+
version: 1,
|
|
3077
|
+
workspace: workspaceRoot,
|
|
3078
|
+
intentRef,
|
|
3079
|
+
intentPath: relativeToWorkspace(workspaceRoot, intentPath),
|
|
3080
|
+
exists,
|
|
3081
|
+
active,
|
|
3082
|
+
nextSteps: active
|
|
3083
|
+
? [
|
|
3084
|
+
"Run ripple gate --intent latest before continuing.",
|
|
3085
|
+
"Run ripple intent close --reason \"<why this boundary is done>\" when the task boundary is complete or intentionally replaced.",
|
|
3086
|
+
]
|
|
3087
|
+
: [
|
|
3088
|
+
"Run ripple plan --file <file> --task \"<task>\" --agent --save before an agent edits.",
|
|
3089
|
+
],
|
|
3090
|
+
};
|
|
3091
|
+
if (active) {
|
|
3092
|
+
output.intent = intentSnapshot((0, core_1.loadChangeIntent)(workspaceRoot, intentRef));
|
|
3093
|
+
}
|
|
3094
|
+
if (options.json) {
|
|
3095
|
+
printJson(output);
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
printIntentStatus(output);
|
|
3099
|
+
}
|
|
3100
|
+
function closeIntentCommand(options) {
|
|
3101
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
3102
|
+
const intentRef = options.intent ?? "latest";
|
|
3103
|
+
const intentPath = resolveCliIntentPath(workspaceRoot, intentRef);
|
|
3104
|
+
if (!fs.existsSync(intentPath) || !isActiveChangeIntentFile(intentPath)) {
|
|
3105
|
+
throw new Error(`No active Ripple intent exists at ${relativeToWorkspace(workspaceRoot, intentPath)}.`);
|
|
3106
|
+
}
|
|
3107
|
+
const reason = options.reason?.trim();
|
|
3108
|
+
if (!reason) {
|
|
3109
|
+
throw new Error("Closing an active Ripple intent requires --reason.");
|
|
3110
|
+
}
|
|
3111
|
+
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
3112
|
+
const closedAt = new Date().toISOString();
|
|
3113
|
+
const closedBy = options.approvedBy?.trim() || "human";
|
|
3114
|
+
const archivePath = archivedIntentPath(workspaceRoot, intent, closedAt);
|
|
3115
|
+
const archive = {
|
|
3116
|
+
protocol: "ripple-closed-intent",
|
|
3117
|
+
version: 1,
|
|
3118
|
+
closedAt,
|
|
3119
|
+
closedBy,
|
|
3120
|
+
reason,
|
|
3121
|
+
originalIntentPath: relativeToWorkspace(workspaceRoot, intentPath),
|
|
3122
|
+
intent,
|
|
3123
|
+
};
|
|
3124
|
+
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
|
3125
|
+
fs.writeFileSync(archivePath, `${JSON.stringify(archive, null, 2)}\n`, "utf8");
|
|
3126
|
+
fs.writeFileSync(intentPath, `${JSON.stringify(archive, null, 2)}\n`, "utf8");
|
|
3127
|
+
const output = {
|
|
3128
|
+
protocol: "ripple-intent-close",
|
|
3129
|
+
version: 1,
|
|
3130
|
+
workspace: workspaceRoot,
|
|
3131
|
+
intentRef,
|
|
3132
|
+
intentPath: relativeToWorkspace(workspaceRoot, intentPath),
|
|
3133
|
+
archivePath: relativeToWorkspace(workspaceRoot, archivePath),
|
|
3134
|
+
closedAt,
|
|
3135
|
+
closedBy,
|
|
3136
|
+
reason,
|
|
3137
|
+
intent: intentSnapshot(intent),
|
|
3138
|
+
nextSteps: [
|
|
3139
|
+
"Run ripple intent status to confirm no active saved boundary remains.",
|
|
3140
|
+
"Run ripple plan --file <file> --task \"<task>\" --agent --save to start the next agent boundary.",
|
|
3141
|
+
],
|
|
3142
|
+
};
|
|
3143
|
+
if (options.json) {
|
|
3144
|
+
printJson(output);
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
printIntentClose(output);
|
|
3148
|
+
}
|
|
3149
|
+
function archivedIntentPath(workspaceRoot, intent, closedAt) {
|
|
3150
|
+
const timestamp = closedAt.replace(/[:.]/g, "-");
|
|
3151
|
+
const safeId = intent.id.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
3152
|
+
return path.join(workspaceRoot, ".ripple", "intents", "archive", `${timestamp}-${safeId}.json`);
|
|
3153
|
+
}
|
|
3154
|
+
function printIntentStatus(summary) {
|
|
3155
|
+
console.log("Ripple intent status");
|
|
3156
|
+
console.log(`Intent: ${summary.intentRef}`);
|
|
3157
|
+
console.log(`Path: ${summary.intentPath}`);
|
|
3158
|
+
console.log(`Active: ${formatYesNo(summary.active)}`);
|
|
3159
|
+
if (summary.intent) {
|
|
3160
|
+
console.log(`Id: ${summary.intent.id}`);
|
|
3161
|
+
console.log(`Task: ${summary.intent.task}`);
|
|
3162
|
+
console.log(`Target: ${summary.intent.targetFile}`);
|
|
3163
|
+
console.log(`Control mode: ${summary.intent.controlMode}`);
|
|
3164
|
+
console.log(`Human gate: ${summary.intent.humanGate}`);
|
|
3165
|
+
console.log(`Boundary risk: ${summary.intent.boundaryRisk}`);
|
|
3166
|
+
}
|
|
3167
|
+
printHumanList("Next:", summary.nextSteps);
|
|
3168
|
+
}
|
|
3169
|
+
function printIntentClose(summary) {
|
|
3170
|
+
console.log("Ripple intent closed");
|
|
3171
|
+
console.log(`Intent: ${summary.intent.id}`);
|
|
3172
|
+
console.log(`Task: ${summary.intent.task}`);
|
|
3173
|
+
console.log(`Target: ${summary.intent.targetFile}`);
|
|
3174
|
+
console.log(`Closed by: ${summary.closedBy}`);
|
|
3175
|
+
console.log(`Reason: ${summary.reason}`);
|
|
3176
|
+
console.log(`Archived: ${summary.archivePath}`);
|
|
3177
|
+
console.log(`Closed marker: ${summary.intentPath}`);
|
|
3178
|
+
printHumanList("Next:", summary.nextSteps);
|
|
3179
|
+
}
|
|
2422
3180
|
function approvalStatusOutput(intent, status) {
|
|
2423
3181
|
return {
|
|
2424
3182
|
...status,
|
|
@@ -2478,32 +3236,60 @@ async function buildCheckSummaryForFiles(input) {
|
|
|
2478
3236
|
engine.dispose();
|
|
2479
3237
|
}
|
|
2480
3238
|
}
|
|
2481
|
-
|
|
2482
|
-
if (
|
|
2483
|
-
|
|
3239
|
+
function selectedChangeMode(options) {
|
|
3240
|
+
if (options.changed) {
|
|
3241
|
+
return "changed";
|
|
3242
|
+
}
|
|
3243
|
+
if (options.worktree) {
|
|
3244
|
+
return "worktree";
|
|
3245
|
+
}
|
|
3246
|
+
return "staged";
|
|
3247
|
+
}
|
|
3248
|
+
function selectedChangeModeCount(options) {
|
|
3249
|
+
return [options.staged, options.changed, options.worktree].filter(Boolean).length;
|
|
3250
|
+
}
|
|
3251
|
+
function listFilesForChangeMode(workspaceRoot, mode, baseRef) {
|
|
3252
|
+
if (mode === "changed") {
|
|
3253
|
+
return (0, core_1.listGitChangedFiles)(workspaceRoot, baseRef);
|
|
2484
3254
|
}
|
|
2485
|
-
if (
|
|
2486
|
-
|
|
3255
|
+
if (mode === "worktree") {
|
|
3256
|
+
return (0, core_1.listGitWorktreeFiles)(workspaceRoot);
|
|
3257
|
+
}
|
|
3258
|
+
return (0, core_1.listGitStagedFiles)(workspaceRoot);
|
|
3259
|
+
}
|
|
3260
|
+
async function checkCommand(options) {
|
|
3261
|
+
if (selectedChangeModeCount(options) !== 1) {
|
|
3262
|
+
throw new Error("Choose one check mode: --staged, --worktree, or --changed --base <ref>");
|
|
2487
3263
|
}
|
|
2488
3264
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2489
3265
|
const baseRef = options.base ?? "HEAD";
|
|
2490
|
-
const
|
|
2491
|
-
|
|
2492
|
-
|
|
3266
|
+
const mode = selectedChangeMode(options);
|
|
3267
|
+
let loadedIntent;
|
|
3268
|
+
if (options.intent) {
|
|
3269
|
+
try {
|
|
3270
|
+
loadedIntent = (0, core_1.loadChangeIntent)(workspaceRoot, options.intent);
|
|
3271
|
+
}
|
|
3272
|
+
catch (err) {
|
|
3273
|
+
if (!options.strict && !options.githubAnnotations) {
|
|
3274
|
+
throw err;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
const checkFiles = listFilesForChangeMode(workspaceRoot, mode, baseRef);
|
|
2493
3279
|
const engine = createFastCheckEngine(workspaceRoot);
|
|
2494
3280
|
try {
|
|
2495
3281
|
await runWithQuietEngine(() => engine.fastCheckScan(fastCheckCandidateFiles(checkFiles)));
|
|
2496
3282
|
const stagedSummary = (0, core_1.buildStagedCheckSummary)(engine, {
|
|
2497
3283
|
workspaceRoot,
|
|
2498
3284
|
stagedFiles: checkFiles,
|
|
2499
|
-
mode
|
|
2500
|
-
baseRef:
|
|
3285
|
+
mode,
|
|
3286
|
+
baseRef: mode === "changed" ? baseRef : undefined,
|
|
2501
3287
|
tokenBudget: options.budget,
|
|
2502
3288
|
});
|
|
2503
3289
|
let summary = stagedSummary;
|
|
2504
3290
|
if (options.intent) {
|
|
2505
3291
|
try {
|
|
2506
|
-
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, options.intent);
|
|
3292
|
+
const intent = loadedIntent ?? (0, core_1.loadChangeIntent)(workspaceRoot, options.intent);
|
|
2507
3293
|
summary = (0, core_1.validateStagedCheckAgainstIntent)(stagedSummary, intent, {
|
|
2508
3294
|
currentPolicyExplanation: currentPolicyExplanationForIntent(workspaceRoot, intent),
|
|
2509
3295
|
currentReadinessSnapshot: currentReadinessSnapshotForEngine(workspaceRoot, engine),
|
|
@@ -2571,18 +3357,16 @@ async function checkCommand(options) {
|
|
|
2571
3357
|
}
|
|
2572
3358
|
}
|
|
2573
3359
|
async function buildAuditFromCliOptions(options) {
|
|
2574
|
-
if (options
|
|
2575
|
-
throw new Error("Choose one gate/audit mode: --staged or --changed");
|
|
3360
|
+
if (selectedChangeModeCount(options) > 1) {
|
|
3361
|
+
throw new Error("Choose one gate/audit mode: --staged, --worktree, or --changed --base <ref>");
|
|
2576
3362
|
}
|
|
2577
3363
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2578
3364
|
const intentRef = options.intent ?? "latest";
|
|
2579
|
-
const mode = options
|
|
3365
|
+
const mode = selectedChangeMode(options);
|
|
2580
3366
|
const baseRef = options.base ?? "HEAD";
|
|
2581
|
-
const files = mode === "changed"
|
|
2582
|
-
? (0, core_1.listGitChangedFiles)(workspaceRoot, baseRef)
|
|
2583
|
-
: (0, core_1.listGitStagedFiles)(workspaceRoot);
|
|
2584
3367
|
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
2585
3368
|
const currentPolicyExplanation = currentPolicyExplanationForIntent(workspaceRoot, intent);
|
|
3369
|
+
const files = listFilesForChangeMode(workspaceRoot, mode, baseRef);
|
|
2586
3370
|
return buildAuditForFiles({
|
|
2587
3371
|
workspaceRoot,
|
|
2588
3372
|
files,
|
|
@@ -2610,6 +3394,36 @@ async function auditCommand(options) {
|
|
|
2610
3394
|
applyStrictExit(options.strict && strictAuditShouldFail(audit));
|
|
2611
3395
|
}
|
|
2612
3396
|
async function gateCommand(options) {
|
|
3397
|
+
if (selectedChangeModeCount(options) > 1) {
|
|
3398
|
+
throw new Error("Choose one gate/audit mode: --staged, --worktree, or --changed --base <ref>");
|
|
3399
|
+
}
|
|
3400
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
3401
|
+
const intentRef = options.intent ?? "latest";
|
|
3402
|
+
const mode = selectedChangeMode(options);
|
|
3403
|
+
const baseRef = options.base ?? "HEAD";
|
|
3404
|
+
try {
|
|
3405
|
+
(0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
3406
|
+
}
|
|
3407
|
+
catch (err) {
|
|
3408
|
+
const block = (0, core_1.buildRippleGateIntentBlockSummary)({
|
|
3409
|
+
workspaceRoot,
|
|
3410
|
+
mode,
|
|
3411
|
+
baseRef: mode === "changed" ? baseRef : undefined,
|
|
3412
|
+
intentRef,
|
|
3413
|
+
error: err,
|
|
3414
|
+
});
|
|
3415
|
+
if (options.json) {
|
|
3416
|
+
printJson(block);
|
|
3417
|
+
}
|
|
3418
|
+
else if (options.agent) {
|
|
3419
|
+
printAgentGateIntentBlock(block);
|
|
3420
|
+
}
|
|
3421
|
+
else {
|
|
3422
|
+
printGateIntentBlock(block);
|
|
3423
|
+
}
|
|
3424
|
+
applyStrictExit(true);
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
2613
3427
|
const audit = await buildAuditFromCliOptions(options);
|
|
2614
3428
|
const gate = (0, core_1.buildRippleGateSummary)(audit);
|
|
2615
3429
|
if (options.json) {
|
|
@@ -2621,11 +3435,67 @@ async function gateCommand(options) {
|
|
|
2621
3435
|
else {
|
|
2622
3436
|
printGateSummary(gate);
|
|
2623
3437
|
}
|
|
3438
|
+
// ─── Ripple Cloud Sync ───────────────────────────────────────────────────
|
|
3439
|
+
// Records the gate decision to ripple-cloud for the compliance audit trail.
|
|
3440
|
+
// Runs on EVERY gate call — including the pre-commit hook blocking scenario.
|
|
3441
|
+
// Never blocks. Never throws. The commit result is unaffected by cloud sync.
|
|
3442
|
+
if (process.env.RIPPLE_API_KEY) {
|
|
3443
|
+
const cloudDecision = gate.canContinue ? "continue" :
|
|
3444
|
+
gate.needsHuman ? "human-review" : "blocked";
|
|
3445
|
+
const commitSha = (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
|
|
3446
|
+
const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
|
|
3447
|
+
const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
|
|
3448
|
+
const cloudResult = await (0, core_1.syncAuditToCloud)({
|
|
3449
|
+
intentId: audit.intent.id,
|
|
3450
|
+
decision: cloudDecision,
|
|
3451
|
+
commitSha,
|
|
3452
|
+
branch,
|
|
3453
|
+
actor,
|
|
3454
|
+
source: "cli",
|
|
3455
|
+
payload: {
|
|
3456
|
+
// Identity
|
|
3457
|
+
intentId: audit.intent.id,
|
|
3458
|
+
commitSha,
|
|
3459
|
+
branch,
|
|
3460
|
+
actor,
|
|
3461
|
+
source: "pre-commit-gate",
|
|
3462
|
+
// Intent declared scope — what the developer said they would change
|
|
3463
|
+
task: audit.intent.task,
|
|
3464
|
+
targetFile: audit.intent.targetFile,
|
|
3465
|
+
controlMode: audit.intent.controlMode,
|
|
3466
|
+
boundaryRisk: audit.intent.boundaryRisk,
|
|
3467
|
+
humanGate: audit.intent.humanGate,
|
|
3468
|
+
// Gate verdict — what Ripple actually decided
|
|
3469
|
+
decision: cloudDecision,
|
|
3470
|
+
status: gate.status,
|
|
3471
|
+
canContinue: gate.canContinue,
|
|
3472
|
+
mustStop: gate.mustStop,
|
|
3473
|
+
needsHuman: gate.needsHuman,
|
|
3474
|
+
// Evidence — what actually changed
|
|
3475
|
+
blockingReasons: audit.blockingReasons ?? [],
|
|
3476
|
+
changedFiles: audit.changedFiles ?? [],
|
|
3477
|
+
changedOutsideBoundaryFiles: audit.stagedCheck?.intentValidation?.boundaryVerdict?.changedOutsideBoundaryFiles ?? [],
|
|
3478
|
+
},
|
|
3479
|
+
});
|
|
3480
|
+
if (!options.json && !options.agent) {
|
|
3481
|
+
if (cloudResult.sent) {
|
|
3482
|
+
const icon = cloudDecision === "continue" ? "✓" : "⛔";
|
|
3483
|
+
console.log(`\n[Ripple Cloud] ${icon} Gate decision recorded: ${cloudDecision} (${commitSha.slice(0, 7)})`);
|
|
3484
|
+
}
|
|
3485
|
+
else if (cloudResult.error) {
|
|
3486
|
+
console.warn(`\n[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2624
3491
|
applyStrictExit(options.strict && !gate.canContinue);
|
|
2625
3492
|
}
|
|
2626
3493
|
function approveCommand(options) {
|
|
2627
3494
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2628
3495
|
const intentRef = options.intent ?? "latest";
|
|
3496
|
+
if (!options.reason || options.reason.trim().length === 0) {
|
|
3497
|
+
throw new Error("Approval requires --reason explaining why this boundary is approved.");
|
|
3498
|
+
}
|
|
2629
3499
|
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
2630
3500
|
const approval = (0, core_1.recordRippleApproval)(workspaceRoot, intent, {
|
|
2631
3501
|
gate: options.gate,
|
|
@@ -2642,6 +3512,171 @@ function approveCommand(options) {
|
|
|
2642
3512
|
}
|
|
2643
3513
|
printApprovalRecord(approval);
|
|
2644
3514
|
}
|
|
3515
|
+
function executeVerificationCommand(workspaceRoot, command, note) {
|
|
3516
|
+
const startedAt = Date.now();
|
|
3517
|
+
const result = (0, child_process_1.spawnSync)(command, {
|
|
3518
|
+
cwd: workspaceRoot,
|
|
3519
|
+
shell: true,
|
|
3520
|
+
encoding: "utf8",
|
|
3521
|
+
maxBuffer: 1024 * 1024,
|
|
3522
|
+
windowsHide: true,
|
|
3523
|
+
});
|
|
3524
|
+
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
3525
|
+
const exitCode = typeof result.status === "number" ? result.status : 1;
|
|
3526
|
+
const stderr = [
|
|
3527
|
+
typeof result.stderr === "string" ? result.stderr : "",
|
|
3528
|
+
result.error ? result.error.message : "",
|
|
3529
|
+
].filter(Boolean).join("\n");
|
|
3530
|
+
return {
|
|
3531
|
+
command,
|
|
3532
|
+
status: exitCode === 0 ? "passed" : "failed",
|
|
3533
|
+
exitCode,
|
|
3534
|
+
durationMs,
|
|
3535
|
+
stdoutTail: outputTail(typeof result.stdout === "string" ? result.stdout : ""),
|
|
3536
|
+
stderrTail: outputTail(stderr),
|
|
3537
|
+
note,
|
|
3538
|
+
};
|
|
3539
|
+
}
|
|
3540
|
+
function outputTail(value, maxLength = 4000) {
|
|
3541
|
+
const normalized = value.replace(/\r\n/g, "\n").trim();
|
|
3542
|
+
if (normalized.length === 0) {
|
|
3543
|
+
return undefined;
|
|
3544
|
+
}
|
|
3545
|
+
return normalized.length > maxLength ? normalized.slice(-maxLength) : normalized;
|
|
3546
|
+
}
|
|
3547
|
+
function currentChangeSnapshotForVerification(workspaceRoot, intent) {
|
|
3548
|
+
const scope = verificationSnapshotScope(intent);
|
|
3549
|
+
const snapshot = (changedFiles, diff, changeMode) => ({
|
|
3550
|
+
changedFiles: changedFiles
|
|
3551
|
+
.map(normalizeProjectPath)
|
|
3552
|
+
.filter(core_1.isRippleSourceFile)
|
|
3553
|
+
.filter((file) => scope.size === 0 || scope.has(file)),
|
|
3554
|
+
changeMode,
|
|
3555
|
+
changeFingerprint: (0, core_1.fingerprintRippleChangeDiff)(diff),
|
|
3556
|
+
});
|
|
3557
|
+
try {
|
|
3558
|
+
const diff = (0, core_1.listGitChangedDiff)(workspaceRoot, "HEAD");
|
|
3559
|
+
const changedFiles = (0, core_1.listGitChangedFiles)(workspaceRoot, "HEAD");
|
|
3560
|
+
const changedSnapshot = snapshot(changedFiles, diff, "changed");
|
|
3561
|
+
if (changedSnapshot.changedFiles.length > 0) {
|
|
3562
|
+
return changedSnapshot;
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
catch {
|
|
3566
|
+
// Repositories without a HEAD commit fall back to staged/worktree snapshots.
|
|
3567
|
+
}
|
|
3568
|
+
try {
|
|
3569
|
+
const diff = (0, core_1.listGitStagedDiff)(workspaceRoot);
|
|
3570
|
+
const stagedSnapshot = snapshot((0, core_1.listGitStagedFiles)(workspaceRoot), diff, "staged");
|
|
3571
|
+
if (stagedSnapshot.changedFiles.length > 0) {
|
|
3572
|
+
return stagedSnapshot;
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
catch {
|
|
3576
|
+
// Fall through to worktree or empty coverage.
|
|
3577
|
+
}
|
|
3578
|
+
try {
|
|
3579
|
+
return snapshot((0, core_1.listGitWorktreeFiles)(workspaceRoot), (0, core_1.listGitWorktreeDiff)(workspaceRoot), "worktree");
|
|
3580
|
+
}
|
|
3581
|
+
catch {
|
|
3582
|
+
return { changedFiles: [], changeMode: "staged" };
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
function verificationSnapshotScope(intent) {
|
|
3586
|
+
return new Set([
|
|
3587
|
+
...intent.editableFiles,
|
|
3588
|
+
...intent.expectedFiles,
|
|
3589
|
+
intent.targetFile,
|
|
3590
|
+
].map(normalizeProjectPath));
|
|
3591
|
+
}
|
|
3592
|
+
function verifyCommand(options) {
|
|
3593
|
+
const workspaceRoot = process.cwd();
|
|
3594
|
+
const intentRef = options.intent ?? "latest";
|
|
3595
|
+
const reportedCommand = options.verificationCommand?.trim();
|
|
3596
|
+
const runCommand = options.verificationRunCommand?.trim();
|
|
3597
|
+
if (reportedCommand && runCommand) {
|
|
3598
|
+
throw new Error("Use either --run for Ripple-executed evidence or --command/--status for reported evidence, not both.");
|
|
3599
|
+
}
|
|
3600
|
+
if (!reportedCommand && !runCommand) {
|
|
3601
|
+
throw new Error("Usage: ripple verify --run <test command> [--intent latest|path] or ripple verify --command <test command> --status passed|failed|skipped|unknown [--intent latest|path]");
|
|
3602
|
+
}
|
|
3603
|
+
if (runCommand && options.verificationStatus) {
|
|
3604
|
+
throw new Error("--status is only valid with --command. Use --run to let Ripple compute passed/failed from the exit code.");
|
|
3605
|
+
}
|
|
3606
|
+
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
3607
|
+
const executed = runCommand
|
|
3608
|
+
? executeVerificationCommand(workspaceRoot, runCommand, options.note)
|
|
3609
|
+
: undefined;
|
|
3610
|
+
const changeSnapshot = currentChangeSnapshotForVerification(workspaceRoot, intent);
|
|
3611
|
+
const updatedIntent = (0, core_1.appendRippleVerificationEvidence)(intent, {
|
|
3612
|
+
command: executed?.command ?? reportedCommand ?? "",
|
|
3613
|
+
status: executed?.status ?? options.verificationStatus ?? "unknown",
|
|
3614
|
+
source: executed ? "executed" : "reported",
|
|
3615
|
+
changedFiles: changeSnapshot.changedFiles,
|
|
3616
|
+
changeMode: changeSnapshot.changeMode,
|
|
3617
|
+
changeFingerprint: changeSnapshot.changeFingerprint,
|
|
3618
|
+
exitCode: executed?.exitCode,
|
|
3619
|
+
durationMs: executed?.durationMs,
|
|
3620
|
+
stdoutTail: executed?.stdoutTail,
|
|
3621
|
+
stderrTail: executed?.stderrTail,
|
|
3622
|
+
note: executed?.note ?? options.note,
|
|
3623
|
+
});
|
|
3624
|
+
const intentPath = (0, core_1.saveChangeIntent)(workspaceRoot, updatedIntent, intentRef);
|
|
3625
|
+
const evidence = updatedIntent.verificationEvidence[updatedIntent.verificationEvidence.length - 1];
|
|
3626
|
+
const evidenceSourceLabel = evidence.source === "executed"
|
|
3627
|
+
? "Ripple executed this command and recorded its exit code."
|
|
3628
|
+
: "Ripple recorded reported evidence only; it did not independently run this command.";
|
|
3629
|
+
const output = {
|
|
3630
|
+
protocol: "ripple-verification-evidence",
|
|
3631
|
+
version: 1,
|
|
3632
|
+
workspace: workspaceRoot,
|
|
3633
|
+
intentPath,
|
|
3634
|
+
intentId: updatedIntent.id,
|
|
3635
|
+
evidence,
|
|
3636
|
+
totalEvidence: updatedIntent.verificationEvidence.length,
|
|
3637
|
+
nextSteps: [
|
|
3638
|
+
"Run ripple gate --intent latest --json to include this evidence in the review packet.",
|
|
3639
|
+
evidence.status === "failed"
|
|
3640
|
+
? "Fix the failing verification, rerun ripple verify --run, then run ripple gate again."
|
|
3641
|
+
: "Run ripple gate again before handoff so the continue/stop decision includes this evidence.",
|
|
3642
|
+
evidenceSourceLabel,
|
|
3643
|
+
],
|
|
3644
|
+
};
|
|
3645
|
+
if (options.json) {
|
|
3646
|
+
printJson(output);
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
console.log("Ripple verification evidence");
|
|
3650
|
+
console.log(`Intent: ${output.intentId}`);
|
|
3651
|
+
console.log(`Intent path: ${path.relative(workspaceRoot, output.intentPath) || output.intentPath}`);
|
|
3652
|
+
console.log(`Status: ${output.evidence.status}`);
|
|
3653
|
+
console.log(`Command: ${output.evidence.command}`);
|
|
3654
|
+
console.log(`Source: ${output.evidence.source}`);
|
|
3655
|
+
if (typeof output.evidence.exitCode === "number") {
|
|
3656
|
+
console.log(`Exit code: ${output.evidence.exitCode}`);
|
|
3657
|
+
}
|
|
3658
|
+
if (typeof output.evidence.durationMs === "number") {
|
|
3659
|
+
console.log(`Duration ms: ${output.evidence.durationMs}`);
|
|
3660
|
+
}
|
|
3661
|
+
if (output.evidence.changedFiles) {
|
|
3662
|
+
printHumanList("Changed files covered:", output.evidence.changedFiles);
|
|
3663
|
+
}
|
|
3664
|
+
if (output.evidence.changeMode) {
|
|
3665
|
+
console.log(`Change mode: ${output.evidence.changeMode}`);
|
|
3666
|
+
}
|
|
3667
|
+
if (output.evidence.changeFingerprint) {
|
|
3668
|
+
console.log(`Change fingerprint: ${output.evidence.changeFingerprint.slice(0, 12)}`);
|
|
3669
|
+
}
|
|
3670
|
+
console.log(`Recorded at: ${output.evidence.recordedAt}`);
|
|
3671
|
+
if (output.evidence.note) {
|
|
3672
|
+
console.log(`Note: ${output.evidence.note}`);
|
|
3673
|
+
}
|
|
3674
|
+
if (output.evidence.stderrTail) {
|
|
3675
|
+
console.log("Stderr tail:");
|
|
3676
|
+
console.log(output.evidence.stderrTail);
|
|
3677
|
+
}
|
|
3678
|
+
console.log(`Note: ${evidenceSourceLabel}`);
|
|
3679
|
+
}
|
|
2645
3680
|
function approvalCommand(options) {
|
|
2646
3681
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2647
3682
|
const intentRef = options.intent ?? "latest";
|
|
@@ -2660,9 +3695,94 @@ function approvalCommand(options) {
|
|
|
2660
3695
|
async function ciCommand(options) {
|
|
2661
3696
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2662
3697
|
const baseRef = options.base ?? defaultCiBaseRef();
|
|
3698
|
+
const hasExplicitIntent = Boolean(options.intent);
|
|
2663
3699
|
const intentRef = options.intent ?? "latest";
|
|
2664
3700
|
const files = (0, core_1.listGitChangedFiles)(workspaceRoot, baseRef);
|
|
2665
3701
|
const emitGithubAnnotations = shouldEmitGithubAnnotations(options);
|
|
3702
|
+
if (!hasExplicitIntent) {
|
|
3703
|
+
const summary = await buildCheckSummaryForFiles({
|
|
3704
|
+
workspaceRoot,
|
|
3705
|
+
files,
|
|
3706
|
+
mode: "changed",
|
|
3707
|
+
baseRef,
|
|
3708
|
+
tokenBudget: options.budget,
|
|
3709
|
+
});
|
|
3710
|
+
const policySync = buildPolicySyncSummary(workspaceRoot);
|
|
3711
|
+
if (options.json) {
|
|
3712
|
+
printJson({
|
|
3713
|
+
...summary,
|
|
3714
|
+
protocol: "ripple-ci-policy-audit",
|
|
3715
|
+
version: 1,
|
|
3716
|
+
auditMode: true,
|
|
3717
|
+
blocking: false,
|
|
3718
|
+
intentRequired: false,
|
|
3719
|
+
policySync,
|
|
3720
|
+
});
|
|
3721
|
+
}
|
|
3722
|
+
else if (options.agent) {
|
|
3723
|
+
printAgentStagedCheckSummary(summary);
|
|
3724
|
+
console.log("");
|
|
3725
|
+
console.log("ci_mode: policy-audit");
|
|
3726
|
+
console.log("blocking: false");
|
|
3727
|
+
console.log("intent_required: false");
|
|
3728
|
+
console.log(`policy_sync: ${policySync.status}`);
|
|
3729
|
+
if (policySync.missingRules.length > 0) {
|
|
3730
|
+
console.log("policy_sync_missing_rules:");
|
|
3731
|
+
policySync.missingRules.slice(0, 12).forEach((rule) => {
|
|
3732
|
+
console.log(`- ${rule.paths.join(", ")} risk=${rule.risk ?? "medium"}`);
|
|
3733
|
+
});
|
|
3734
|
+
}
|
|
3735
|
+
console.log("next_required_action: Review policy-risk findings and policy-sync warnings before merge. Use --intent latest --strict only when you want an intent-bound hard gate.");
|
|
3736
|
+
}
|
|
3737
|
+
else {
|
|
3738
|
+
console.log("Ripple CI policy audit");
|
|
3739
|
+
console.log("Status: audit");
|
|
3740
|
+
console.log("Blocking: false");
|
|
3741
|
+
console.log("Intent: none (local intents are not required in CI audit mode)");
|
|
3742
|
+
console.log(`Policy sync: ${policySync.status}`);
|
|
3743
|
+
if (policySync.missingRules.length > 0) {
|
|
3744
|
+
console.log("");
|
|
3745
|
+
console.log("Policy may be missing risky repo surfaces:");
|
|
3746
|
+
policySync.missingRules.slice(0, 12).forEach((rule) => {
|
|
3747
|
+
console.log(`- ${rule.paths.join(", ")} risk=${rule.risk ?? "medium"}`);
|
|
3748
|
+
});
|
|
3749
|
+
}
|
|
3750
|
+
console.log("");
|
|
3751
|
+
printStagedCheckSummary(summary);
|
|
3752
|
+
console.log("");
|
|
3753
|
+
console.log("Next action: Review policy-risk findings and policy-sync warnings before merge. Use --intent latest --strict only when you want an intent-bound hard gate.");
|
|
3754
|
+
}
|
|
3755
|
+
if (emitGithubAnnotations && !options.json) {
|
|
3756
|
+
printGithubPolicyAuditAnnotations(summary, policySync);
|
|
3757
|
+
}
|
|
3758
|
+
writeGithubPolicyAuditStepSummary(summary, policySync);
|
|
3759
|
+
// FOUNDER FIX: Add Cloud Sync to the Policy Audit Path
|
|
3760
|
+
if (process.env.RIPPLE_API_KEY) {
|
|
3761
|
+
const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
|
|
3762
|
+
const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
|
|
3763
|
+
const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
|
|
3764
|
+
const syntheticIntentId = `ci-policy-${commitSha.slice(0, 16)}`;
|
|
3765
|
+
const cloudResult = await (0, core_1.syncAuditToCloud)({
|
|
3766
|
+
protocol: 'ripple-audit', // FOUNDER FIX: Satisfy the strict Zod Cloud Schema
|
|
3767
|
+
intentId: syntheticIntentId,
|
|
3768
|
+
decision: 'continue',
|
|
3769
|
+
commitSha,
|
|
3770
|
+
branch,
|
|
3771
|
+
actor,
|
|
3772
|
+
source: 'ci',
|
|
3773
|
+
payload: { mode: 'policy-audit', commitSha, branch, actor },
|
|
3774
|
+
});
|
|
3775
|
+
if (!options.json && !options.agent) {
|
|
3776
|
+
if (cloudResult.sent) {
|
|
3777
|
+
console.log(`\n[Ripple Cloud] ✓ Policy audit synced (${commitSha.slice(0, 7)})`);
|
|
3778
|
+
}
|
|
3779
|
+
else if (cloudResult.error) {
|
|
3780
|
+
console.warn(`\n[Ripple Cloud] ⚠ Sync failed: ${cloudResult.error}`);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
2666
3786
|
let intent;
|
|
2667
3787
|
try {
|
|
2668
3788
|
intent = (0, core_1.loadChangeIntent)(workspaceRoot, intentRef);
|
|
@@ -2709,7 +3829,7 @@ async function ciCommand(options) {
|
|
|
2709
3829
|
printGithubIntentLoadError(message);
|
|
2710
3830
|
}
|
|
2711
3831
|
writeGithubStepSummary({ summary, intentLoadError: message });
|
|
2712
|
-
|
|
3832
|
+
applyStrictExit(options.strict);
|
|
2713
3833
|
return;
|
|
2714
3834
|
}
|
|
2715
3835
|
const audit = await buildAuditForFiles({
|
|
@@ -2737,7 +3857,45 @@ async function ciCommand(options) {
|
|
|
2737
3857
|
printGithubAuditAnnotations(audit);
|
|
2738
3858
|
}
|
|
2739
3859
|
writeGithubAuditStepSummary(audit);
|
|
2740
|
-
|
|
3860
|
+
// ─── Ripple Cloud Sync ──────────────────────────────────────────────────
|
|
3861
|
+
// Sends the audit result to ripple-cloud so the GitHub App can verify it.
|
|
3862
|
+
// Only runs when RIPPLE_API_KEY is set. Never blocks or throws.
|
|
3863
|
+
if (process.env.RIPPLE_API_KEY) {
|
|
3864
|
+
const gate = (0, core_1.buildRippleGateSummary)(audit);
|
|
3865
|
+
const cloudDecision = gate.canContinue ? "continue" :
|
|
3866
|
+
gate.needsHuman ? "human-review" : "blocked";
|
|
3867
|
+
const commitSha = options.sha ?? (0, core_1.getCurrentCommitSha)(child_process_1.execSync);
|
|
3868
|
+
const branch = (0, core_1.getCurrentBranch)(child_process_1.execSync);
|
|
3869
|
+
const actor = (0, core_1.getCurrentActor)(child_process_1.execSync);
|
|
3870
|
+
const cloudResult = await (0, core_1.syncAuditToCloud)({
|
|
3871
|
+
intentId: intent.id,
|
|
3872
|
+
decision: cloudDecision,
|
|
3873
|
+
commitSha,
|
|
3874
|
+
branch,
|
|
3875
|
+
actor,
|
|
3876
|
+
source: "ci",
|
|
3877
|
+
payload: {
|
|
3878
|
+
intentId: intent.id,
|
|
3879
|
+
commitSha,
|
|
3880
|
+
branch,
|
|
3881
|
+
actor,
|
|
3882
|
+
decision: cloudDecision,
|
|
3883
|
+
gate,
|
|
3884
|
+
violations: audit.violations?.length ?? 0,
|
|
3885
|
+
riskLevel: audit.riskLevel ?? "unknown",
|
|
3886
|
+
},
|
|
3887
|
+
});
|
|
3888
|
+
if (!options.json && !options.agent) {
|
|
3889
|
+
if (cloudResult.sent) {
|
|
3890
|
+
console.log(`[Ripple Cloud] ✓ Audit synced (${commitSha.slice(0, 7)})`);
|
|
3891
|
+
}
|
|
3892
|
+
else if (cloudResult.error) {
|
|
3893
|
+
console.warn(`[Ripple Cloud] ⚠ Sync failed (non-blocking): ${cloudResult.error}`);
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
3898
|
+
applyStrictExit(options.strict && strictAuditShouldFail(audit));
|
|
2741
3899
|
}
|
|
2742
3900
|
async function doctorCommand(options) {
|
|
2743
3901
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
@@ -2745,14 +3903,19 @@ async function doctorCommand(options) {
|
|
|
2745
3903
|
try {
|
|
2746
3904
|
await runWithQuietEngine(() => engine.initialScan());
|
|
2747
3905
|
const summary = (0, core_1.buildRippleReadinessSummary)(workspaceRoot, engine);
|
|
3906
|
+
const policySync = buildPolicySyncSummary(workspaceRoot);
|
|
3907
|
+
const output = {
|
|
3908
|
+
...summary,
|
|
3909
|
+
policySync,
|
|
3910
|
+
};
|
|
2748
3911
|
if (options.json) {
|
|
2749
|
-
printJson(
|
|
3912
|
+
printJson(output);
|
|
2750
3913
|
}
|
|
2751
3914
|
else if (options.agent) {
|
|
2752
|
-
printAgentDoctorSummary(
|
|
3915
|
+
printAgentDoctorSummary(output);
|
|
2753
3916
|
}
|
|
2754
3917
|
else {
|
|
2755
|
-
printDoctorSummary(
|
|
3918
|
+
printDoctorSummary(output);
|
|
2756
3919
|
}
|
|
2757
3920
|
applyStrictExit(options.strict && summary.status !== "ready");
|
|
2758
3921
|
}
|
|
@@ -2762,7 +3925,7 @@ async function doctorCommand(options) {
|
|
|
2762
3925
|
}
|
|
2763
3926
|
async function initCommand(options) {
|
|
2764
3927
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
2765
|
-
const policy = (0, core_1.
|
|
3928
|
+
const { policy } = (0, core_1.buildSmartRipplePolicy)(workspaceRoot);
|
|
2766
3929
|
const policyContents = (0, core_1.formatRipplePolicy)(policy);
|
|
2767
3930
|
const workflow = githubActionsWorkflow();
|
|
2768
3931
|
const gitignoreBlock = rippleGitIgnoreBlock();
|
|
@@ -2785,6 +3948,32 @@ async function initCommand(options) {
|
|
|
2785
3948
|
},
|
|
2786
3949
|
];
|
|
2787
3950
|
if (options.print) {
|
|
3951
|
+
const agentFiles = agentSetupFiles(workspaceRoot);
|
|
3952
|
+
const agentSetup = buildAgentSetupSummary(workspaceRoot, agentFiles.map((file) => ({
|
|
3953
|
+
path: file.path,
|
|
3954
|
+
status: "printed",
|
|
3955
|
+
written: false,
|
|
3956
|
+
overwritten: false,
|
|
3957
|
+
content: file.content,
|
|
3958
|
+
})));
|
|
3959
|
+
const preCommitContent = ripplePreCommitHookScript();
|
|
3960
|
+
const postCommitContent = ripplePostCommitHookScript();
|
|
3961
|
+
const hookPath = preferredHookPath(workspaceRoot, "pre-commit");
|
|
3962
|
+
const postCommitHookPath = preferredHookPath(workspaceRoot, "post-commit");
|
|
3963
|
+
const hooks = {
|
|
3964
|
+
protocol: "ripple-hook-install",
|
|
3965
|
+
version: 1,
|
|
3966
|
+
workspace: workspaceRoot,
|
|
3967
|
+
path: normalizeHookPathForOutput(workspaceRoot, hookPath),
|
|
3968
|
+
postCommitPath: normalizeHookPathForOutput(workspaceRoot, postCommitHookPath),
|
|
3969
|
+
status: "printed",
|
|
3970
|
+
written: false,
|
|
3971
|
+
overwritten: false,
|
|
3972
|
+
content: [preCommitContent, postCommitContent].join("\n--- ripple-post-commit ---\n"),
|
|
3973
|
+
preCommitContent,
|
|
3974
|
+
postCommitContent,
|
|
3975
|
+
nextSteps: ["Review the hook scripts, then run ripple init to write the full local setup."],
|
|
3976
|
+
};
|
|
2788
3977
|
const summary = {
|
|
2789
3978
|
protocol: "ripple-init",
|
|
2790
3979
|
version: 1,
|
|
@@ -2796,20 +3985,30 @@ async function initCommand(options) {
|
|
|
2796
3985
|
overwritten: false,
|
|
2797
3986
|
content: file.content,
|
|
2798
3987
|
})),
|
|
3988
|
+
agentSetup,
|
|
3989
|
+
hooks,
|
|
2799
3990
|
nextSteps: defaultInitNextSteps(),
|
|
2800
3991
|
};
|
|
2801
3992
|
if (options.json) {
|
|
2802
3993
|
printJson(summary);
|
|
2803
3994
|
return;
|
|
2804
3995
|
}
|
|
2805
|
-
process.stdout.write(
|
|
2806
|
-
.flatMap((file) => [`# ${file.path}`, file.content.trimEnd(), ""])
|
|
2807
|
-
.
|
|
3996
|
+
process.stdout.write([
|
|
3997
|
+
...files.flatMap((file) => [`# ${file.path}`, file.content.trimEnd(), ""]),
|
|
3998
|
+
...agentFiles.flatMap((file) => [`# ${file.path}`, file.content.trimEnd(), ""]),
|
|
3999
|
+
"# .git hooks",
|
|
4000
|
+
preCommitContent.trimEnd(),
|
|
4001
|
+
"--- ripple-post-commit ---",
|
|
4002
|
+
postCommitContent.trimEnd(),
|
|
4003
|
+
"",
|
|
4004
|
+
].join("\n"));
|
|
2808
4005
|
return;
|
|
2809
4006
|
}
|
|
2810
4007
|
const writtenFiles = files.map((file) => file.merge
|
|
2811
4008
|
? writeRippleGitIgnoreFile(file.absolutePath)
|
|
2812
4009
|
: writeInitFile(file, options.force));
|
|
4010
|
+
const agentSetup = buildAgentSetupSummary(workspaceRoot, agentSetupFiles(workspaceRoot).map((file) => writeAgentSetupFile(file, options.force)));
|
|
4011
|
+
const hooks = installRippleHooks(workspaceRoot);
|
|
2813
4012
|
const engine = createCliEngine(workspaceRoot);
|
|
2814
4013
|
try {
|
|
2815
4014
|
await runWithQuietEngine(() => engine.initialScan());
|
|
@@ -2819,6 +4018,8 @@ async function initCommand(options) {
|
|
|
2819
4018
|
version: 1,
|
|
2820
4019
|
workspace: workspaceRoot,
|
|
2821
4020
|
files: writtenFiles,
|
|
4021
|
+
agentSetup,
|
|
4022
|
+
hooks,
|
|
2822
4023
|
readiness,
|
|
2823
4024
|
nextSteps: defaultInitNextSteps(readiness),
|
|
2824
4025
|
};
|
|
@@ -2833,6 +4034,65 @@ async function initCommand(options) {
|
|
|
2833
4034
|
engine.dispose();
|
|
2834
4035
|
}
|
|
2835
4036
|
}
|
|
4037
|
+
function writeAgentSetupFile(file, force) {
|
|
4038
|
+
const existed = fs.existsSync(file.absolutePath);
|
|
4039
|
+
const nextSection = normalizeLf(file.content);
|
|
4040
|
+
if (!existed || force) {
|
|
4041
|
+
fs.mkdirSync(path.dirname(file.absolutePath), { recursive: true });
|
|
4042
|
+
fs.writeFileSync(file.absolutePath, ensureTrailingLf(nextSection), "utf8");
|
|
4043
|
+
return {
|
|
4044
|
+
path: file.path,
|
|
4045
|
+
status: existed ? "overwritten" : "written",
|
|
4046
|
+
written: true,
|
|
4047
|
+
overwritten: existed,
|
|
4048
|
+
};
|
|
4049
|
+
}
|
|
4050
|
+
const existing = fs.readFileSync(file.absolutePath, "utf8");
|
|
4051
|
+
const updated = mergeRippleManagedSection(existing, nextSection);
|
|
4052
|
+
if (updated.content === existing) {
|
|
4053
|
+
return {
|
|
4054
|
+
path: file.path,
|
|
4055
|
+
status: "exists",
|
|
4056
|
+
written: false,
|
|
4057
|
+
overwritten: false,
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
fs.writeFileSync(file.absolutePath, updated.content, "utf8");
|
|
4061
|
+
return {
|
|
4062
|
+
path: file.path,
|
|
4063
|
+
status: updated.action,
|
|
4064
|
+
written: true,
|
|
4065
|
+
overwritten: false,
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
function mergeRippleManagedSection(existing, nextSection) {
|
|
4069
|
+
const normalizedExisting = normalizeLf(existing);
|
|
4070
|
+
const start = normalizedExisting.indexOf(RIPPLE_AGENT_SECTION_START);
|
|
4071
|
+
const end = normalizedExisting.indexOf(RIPPLE_AGENT_SECTION_END);
|
|
4072
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
4073
|
+
const afterEnd = end + RIPPLE_AGENT_SECTION_END.length;
|
|
4074
|
+
const withoutOldSection = `${normalizedExisting.slice(0, start)}${normalizedExisting.slice(afterEnd)}`;
|
|
4075
|
+
return {
|
|
4076
|
+
content: appendRippleSectionAtBottom(withoutOldSection, nextSection),
|
|
4077
|
+
action: "updated",
|
|
4078
|
+
};
|
|
4079
|
+
}
|
|
4080
|
+
return {
|
|
4081
|
+
content: appendRippleSectionAtBottom(normalizedExisting, nextSection),
|
|
4082
|
+
action: "appended",
|
|
4083
|
+
};
|
|
4084
|
+
}
|
|
4085
|
+
function appendRippleSectionAtBottom(existing, nextSection) {
|
|
4086
|
+
const base = normalizeLf(existing).replace(/\n*$/, "");
|
|
4087
|
+
const separator = base.length === 0 ? "" : "\n\n";
|
|
4088
|
+
return `${base}${separator}${ensureTrailingLf(normalizeLf(nextSection))}`;
|
|
4089
|
+
}
|
|
4090
|
+
function normalizeLf(value) {
|
|
4091
|
+
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
4092
|
+
}
|
|
4093
|
+
function ensureTrailingLf(value) {
|
|
4094
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
4095
|
+
}
|
|
2836
4096
|
function writeInitFile(file, force) {
|
|
2837
4097
|
const existed = fs.existsSync(file.absolutePath);
|
|
2838
4098
|
if (existed && !force) {
|
|
@@ -2890,6 +4150,315 @@ function gitIgnoreContainsRippleCache(contents) {
|
|
|
2890
4150
|
line === ".ripple/.cache" ||
|
|
2891
4151
|
line === ".ripple/.cache/**");
|
|
2892
4152
|
}
|
|
4153
|
+
const RIPPLE_PRE_COMMIT_HOOK_START = "# >>> ripple pre-commit hook";
|
|
4154
|
+
const RIPPLE_PRE_COMMIT_HOOK_END = "# <<< ripple pre-commit hook";
|
|
4155
|
+
const RIPPLE_POST_COMMIT_HOOK_START = "# >>> ripple post-commit hook";
|
|
4156
|
+
const RIPPLE_POST_COMMIT_HOOK_END = "# <<< ripple post-commit hook";
|
|
4157
|
+
function ripplePreCommitHookBlock() {
|
|
4158
|
+
return [
|
|
4159
|
+
RIPPLE_PRE_COMMIT_HOOK_START,
|
|
4160
|
+
`# Policy is permanent. Intent is local. Git staged diff is truth.
|
|
4161
|
+
ripple_previous_status=$?
|
|
4162
|
+
if [ "$ripple_previous_status" -ne 0 ]; then
|
|
4163
|
+
exit "$ripple_previous_status"
|
|
4164
|
+
fi
|
|
4165
|
+
|
|
4166
|
+
set +e
|
|
4167
|
+
|
|
4168
|
+
ripple_run() {
|
|
4169
|
+
${rippleDirectRunnerHookLines().join("\n")}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
if [ -f ".ripple/intents/latest.json" ]; then
|
|
4173
|
+
echo "[Ripple] Active local intent found. Checking staged changes against approved boundary..."
|
|
4174
|
+
ripple_run gate --staged --intent latest --agent --strict
|
|
4175
|
+
status=$?
|
|
4176
|
+
if [ "$status" -ne 0 ]; then
|
|
4177
|
+
cat <<'EOF'
|
|
4178
|
+
[RIPPLE STOP] Commit blocked by Ripple active-intent boundary.
|
|
4179
|
+
|
|
4180
|
+
If you are an AI agent:
|
|
4181
|
+
- DO NOT retry the commit.
|
|
4182
|
+
- Repair the unauthorized change or ask the human to approve a wider scope.
|
|
4183
|
+
|
|
4184
|
+
If you are a human developer:
|
|
4185
|
+
- Review the Ripple output above.
|
|
4186
|
+
- To bypass this local hook intentionally, run: git commit --no-verify
|
|
4187
|
+
EOF
|
|
4188
|
+
exit $status
|
|
4189
|
+
fi
|
|
4190
|
+
else
|
|
4191
|
+
echo "[Ripple] No active local intent found. Running staged policy/contract awareness check..."
|
|
4192
|
+
ripple_run check --staged --agent
|
|
4193
|
+
status=$?
|
|
4194
|
+
if [ "$status" -ne 0 ]; then
|
|
4195
|
+
cat <<'EOF'
|
|
4196
|
+
[RIPPLE WARNING] Ripple could not complete the no-intent staged check.
|
|
4197
|
+
|
|
4198
|
+
If you are an AI agent:
|
|
4199
|
+
- Stop and ask the human before continuing.
|
|
4200
|
+
|
|
4201
|
+
If you are a human developer:
|
|
4202
|
+
- Review the error above.
|
|
4203
|
+
- To bypass this local hook intentionally, run: git commit --no-verify
|
|
4204
|
+
EOF
|
|
4205
|
+
exit $status
|
|
4206
|
+
fi
|
|
4207
|
+
fi`,
|
|
4208
|
+
RIPPLE_PRE_COMMIT_HOOK_END,
|
|
4209
|
+
"",
|
|
4210
|
+
].join("\n");
|
|
4211
|
+
}
|
|
4212
|
+
function ripplePreCommitHookScript() {
|
|
4213
|
+
return [
|
|
4214
|
+
"#!/bin/sh",
|
|
4215
|
+
"# Ripple pre-commit hook - generated by `ripple hook install`.",
|
|
4216
|
+
ripplePreCommitHookBlock(),
|
|
4217
|
+
"exit 0",
|
|
4218
|
+
"",
|
|
4219
|
+
].join("\n");
|
|
4220
|
+
}
|
|
4221
|
+
function ripplePostCommitHookBlock() {
|
|
4222
|
+
return [
|
|
4223
|
+
RIPPLE_POST_COMMIT_HOOK_START,
|
|
4224
|
+
`# Local intents are consumed after a successful commit.
|
|
4225
|
+
# If RIPPLE_API_KEY is set, the commit is synced to ripple-cloud for GitHub App verification.
|
|
4226
|
+
set +e
|
|
4227
|
+
|
|
4228
|
+
COMMIT_SHA=$(git rev-parse HEAD 2>/dev/null || echo "unknown")
|
|
4229
|
+
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
|
4230
|
+
ACTOR=$(git config user.name 2>/dev/null || git config user.email 2>/dev/null || echo "unknown")
|
|
4231
|
+
CLOUD_URL=\${RIPPLE_CLOUD_URL:-https://your-ripple-cloud.vercel.app}
|
|
4232
|
+
|
|
4233
|
+
INTENT_FILE=""
|
|
4234
|
+
for intent_file in .ripple/.cache/latest-intent.json .ripple/intents/latest.json; do
|
|
4235
|
+
if [ -f "$intent_file" ]; then
|
|
4236
|
+
INTENT_FILE="$intent_file"
|
|
4237
|
+
break
|
|
4238
|
+
fi
|
|
4239
|
+
done
|
|
4240
|
+
|
|
4241
|
+
# Cloud sync — only if API key is set and we have an intent to sync
|
|
4242
|
+
if [ -n "$RIPPLE_API_KEY" ] && [ -n "$INTENT_FILE" ]; then
|
|
4243
|
+
INTENT_ID=$(node -e "try{const d=require('fs').readFileSync(process.argv[1],'utf8');console.log(JSON.parse(d).id||'')}catch(e){console.log('')}" "$INTENT_FILE" 2>/dev/null || echo "")
|
|
4244
|
+
if [ -n "$INTENT_ID" ]; then
|
|
4245
|
+
node -e "
|
|
4246
|
+
const https = require('https');
|
|
4247
|
+
const http = require('http');
|
|
4248
|
+
const url = new URL(process.env.CLOUD_URL + '/api/audit');
|
|
4249
|
+
const isHttps = url.protocol === 'https:';
|
|
4250
|
+
const body = JSON.stringify({
|
|
4251
|
+
intentId: process.env.INTENT_ID,
|
|
4252
|
+
decision: 'continue',
|
|
4253
|
+
commitSha: process.env.COMMIT_SHA,
|
|
4254
|
+
branch: process.env.BRANCH,
|
|
4255
|
+
actor: process.env.ACTOR,
|
|
4256
|
+
source: 'cli',
|
|
4257
|
+
payload: { commitSha: process.env.COMMIT_SHA, source: 'post-commit-hook', branch: process.env.BRANCH }
|
|
4258
|
+
});
|
|
4259
|
+
const lib = isHttps ? https : http;
|
|
4260
|
+
const req = lib.request({
|
|
4261
|
+
hostname: url.hostname,
|
|
4262
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
4263
|
+
path: url.pathname,
|
|
4264
|
+
method: 'POST',
|
|
4265
|
+
headers: {
|
|
4266
|
+
'Content-Type': 'application/json',
|
|
4267
|
+
'Authorization': 'Bearer ' + process.env.RIPPLE_API_KEY,
|
|
4268
|
+
'Content-Length': Buffer.byteLength(body)
|
|
4269
|
+
}
|
|
4270
|
+
}, res => {
|
|
4271
|
+
if (res.statusCode < 300) process.stdout.write('[Ripple Cloud] Commit synced (' + process.env.COMMIT_SHA.slice(0,7) + ')\\n');
|
|
4272
|
+
});
|
|
4273
|
+
req.setTimeout(3000, () => req.destroy());
|
|
4274
|
+
req.on('error', () => {});
|
|
4275
|
+
req.write(body);
|
|
4276
|
+
req.end();
|
|
4277
|
+
" 2>/dev/null || true
|
|
4278
|
+
fi
|
|
4279
|
+
fi
|
|
4280
|
+
|
|
4281
|
+
# Clear the local intent after sync
|
|
4282
|
+
cleared=0
|
|
4283
|
+
for intent_file in .ripple/.cache/latest-intent.json .ripple/intents/latest.json; do
|
|
4284
|
+
if [ -f "$intent_file" ]; then
|
|
4285
|
+
rm "$intent_file"
|
|
4286
|
+
cleared=1
|
|
4287
|
+
fi
|
|
4288
|
+
done
|
|
4289
|
+
|
|
4290
|
+
if [ "$cleared" -eq 1 ]; then
|
|
4291
|
+
echo "[Ripple] Consumed and cleared local intent."
|
|
4292
|
+
fi`,
|
|
4293
|
+
RIPPLE_POST_COMMIT_HOOK_END,
|
|
4294
|
+
"",
|
|
4295
|
+
].join("\n");
|
|
4296
|
+
}
|
|
4297
|
+
function ripplePostCommitHookScript() {
|
|
4298
|
+
return [
|
|
4299
|
+
"#!/bin/sh",
|
|
4300
|
+
"# Ripple post-commit hook - generated by `ripple hook install`.",
|
|
4301
|
+
ripplePostCommitHookBlock(),
|
|
4302
|
+
"exit 0",
|
|
4303
|
+
"",
|
|
4304
|
+
].join("\n");
|
|
4305
|
+
}
|
|
4306
|
+
function normalizeHookPathForOutput(workspaceRoot, hookPath) {
|
|
4307
|
+
return path.relative(workspaceRoot, hookPath).split(path.sep).join("/");
|
|
4308
|
+
}
|
|
4309
|
+
function preferredHookPath(workspaceRoot, hookName) {
|
|
4310
|
+
const huskyDir = path.join(workspaceRoot, ".husky");
|
|
4311
|
+
if (fs.existsSync(huskyDir) && fs.statSync(huskyDir).isDirectory()) {
|
|
4312
|
+
return path.join(huskyDir, hookName);
|
|
4313
|
+
}
|
|
4314
|
+
return path.join(workspaceRoot, ".git", "hooks", hookName);
|
|
4315
|
+
}
|
|
4316
|
+
function installRippleHookBlock(input) {
|
|
4317
|
+
const { hookPath, fullScript, block, marker } = input;
|
|
4318
|
+
if (!fs.existsSync(hookPath)) {
|
|
4319
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
4320
|
+
fs.writeFileSync(hookPath, fullScript, { encoding: "utf8", mode: 0o755 });
|
|
4321
|
+
try {
|
|
4322
|
+
fs.chmodSync(hookPath, 0o755);
|
|
4323
|
+
}
|
|
4324
|
+
catch {
|
|
4325
|
+
// chmod is best-effort on Windows.
|
|
4326
|
+
}
|
|
4327
|
+
return "created";
|
|
4328
|
+
}
|
|
4329
|
+
const existing = fs.readFileSync(hookPath, "utf8");
|
|
4330
|
+
if (existing.includes(marker)) {
|
|
4331
|
+
return "already-present";
|
|
4332
|
+
}
|
|
4333
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
4334
|
+
fs.writeFileSync(hookPath, `${existing}${separator}\n${block}\n`, "utf8");
|
|
4335
|
+
try {
|
|
4336
|
+
fs.chmodSync(hookPath, 0o755);
|
|
4337
|
+
}
|
|
4338
|
+
catch {
|
|
4339
|
+
// chmod is best-effort on Windows.
|
|
4340
|
+
}
|
|
4341
|
+
return "appended";
|
|
4342
|
+
}
|
|
4343
|
+
function installRippleHooks(workspaceRoot) {
|
|
4344
|
+
if (!fs.existsSync(path.join(workspaceRoot, ".git"))) {
|
|
4345
|
+
throw new Error("Cannot install Ripple hook because .git was not found. Run this inside a Git worktree.");
|
|
4346
|
+
}
|
|
4347
|
+
const hookPath = preferredHookPath(workspaceRoot, "pre-commit");
|
|
4348
|
+
const postCommitHookPath = preferredHookPath(workspaceRoot, "post-commit");
|
|
4349
|
+
const content = ripplePreCommitHookScript();
|
|
4350
|
+
const postCommitContent = ripplePostCommitHookScript();
|
|
4351
|
+
const preCommitAction = installRippleHookBlock({
|
|
4352
|
+
hookPath,
|
|
4353
|
+
fullScript: content,
|
|
4354
|
+
block: ripplePreCommitHookBlock(),
|
|
4355
|
+
marker: RIPPLE_PRE_COMMIT_HOOK_START,
|
|
4356
|
+
});
|
|
4357
|
+
const postCommitAction = installRippleHookBlock({
|
|
4358
|
+
hookPath: postCommitHookPath,
|
|
4359
|
+
fullScript: postCommitContent,
|
|
4360
|
+
block: ripplePostCommitHookBlock(),
|
|
4361
|
+
marker: RIPPLE_POST_COMMIT_HOOK_START,
|
|
4362
|
+
});
|
|
4363
|
+
const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
|
|
4364
|
+
return {
|
|
4365
|
+
protocol: "ripple-hook-install",
|
|
4366
|
+
version: 1,
|
|
4367
|
+
workspace: workspaceRoot,
|
|
4368
|
+
path: normalizeHookPathForOutput(workspaceRoot, hookPath),
|
|
4369
|
+
postCommitPath: normalizeHookPathForOutput(workspaceRoot, postCommitHookPath),
|
|
4370
|
+
status: wroteSomething ? "written" : "exists",
|
|
4371
|
+
written: wroteSomething,
|
|
4372
|
+
overwritten: false,
|
|
4373
|
+
preCommitAction,
|
|
4374
|
+
postCommitAction,
|
|
4375
|
+
nextSteps: [
|
|
4376
|
+
"Run ripple plan --file <file> --task \"<task>\" --mode file --agent --save before AI edits.",
|
|
4377
|
+
"Commit normally; Ripple will block active-intent drift and clear consumed local intents after successful commits.",
|
|
4378
|
+
],
|
|
4379
|
+
};
|
|
4380
|
+
}
|
|
4381
|
+
function hookInstallCommand(subcommand, options) {
|
|
4382
|
+
if (subcommand !== "install") {
|
|
4383
|
+
throw new Error("Usage: ripple hook install [--print] [--force]");
|
|
4384
|
+
}
|
|
4385
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
4386
|
+
const hookPath = preferredHookPath(workspaceRoot, "pre-commit");
|
|
4387
|
+
const postCommitHookPath = preferredHookPath(workspaceRoot, "post-commit");
|
|
4388
|
+
const relativeHookPath = normalizeHookPathForOutput(workspaceRoot, hookPath);
|
|
4389
|
+
const relativePostCommitHookPath = normalizeHookPathForOutput(workspaceRoot, postCommitHookPath);
|
|
4390
|
+
const content = ripplePreCommitHookScript();
|
|
4391
|
+
const postCommitContent = ripplePostCommitHookScript();
|
|
4392
|
+
if (options.print) {
|
|
4393
|
+
const summary = {
|
|
4394
|
+
protocol: "ripple-hook-install",
|
|
4395
|
+
version: 1,
|
|
4396
|
+
workspace: workspaceRoot,
|
|
4397
|
+
path: relativeHookPath,
|
|
4398
|
+
postCommitPath: relativePostCommitHookPath,
|
|
4399
|
+
status: "printed",
|
|
4400
|
+
written: false,
|
|
4401
|
+
overwritten: false,
|
|
4402
|
+
content: [content, postCommitContent].join("\n--- ripple-post-commit ---\n"),
|
|
4403
|
+
preCommitContent: content,
|
|
4404
|
+
postCommitContent,
|
|
4405
|
+
nextSteps: ["Review the hook scripts, then run ripple hook install to write them."],
|
|
4406
|
+
};
|
|
4407
|
+
if (options.json) {
|
|
4408
|
+
printJson(summary);
|
|
4409
|
+
}
|
|
4410
|
+
else {
|
|
4411
|
+
process.stdout.write(content);
|
|
4412
|
+
process.stdout.write("\n--- ripple-post-commit ---\n");
|
|
4413
|
+
process.stdout.write(postCommitContent);
|
|
4414
|
+
}
|
|
4415
|
+
return;
|
|
4416
|
+
}
|
|
4417
|
+
if (!fs.existsSync(path.join(workspaceRoot, ".git"))) {
|
|
4418
|
+
throw new Error("Cannot install Ripple hook because .git was not found. Run this inside a Git worktree.");
|
|
4419
|
+
}
|
|
4420
|
+
const preCommitAction = installRippleHookBlock({
|
|
4421
|
+
hookPath,
|
|
4422
|
+
fullScript: content,
|
|
4423
|
+
block: ripplePreCommitHookBlock(),
|
|
4424
|
+
marker: RIPPLE_PRE_COMMIT_HOOK_START,
|
|
4425
|
+
});
|
|
4426
|
+
const postCommitAction = installRippleHookBlock({
|
|
4427
|
+
hookPath: postCommitHookPath,
|
|
4428
|
+
fullScript: postCommitContent,
|
|
4429
|
+
block: ripplePostCommitHookBlock(),
|
|
4430
|
+
marker: RIPPLE_POST_COMMIT_HOOK_START,
|
|
4431
|
+
});
|
|
4432
|
+
const wroteSomething = preCommitAction !== "already-present" || postCommitAction !== "already-present";
|
|
4433
|
+
const summary = {
|
|
4434
|
+
protocol: "ripple-hook-install",
|
|
4435
|
+
version: 1,
|
|
4436
|
+
workspace: workspaceRoot,
|
|
4437
|
+
path: relativeHookPath,
|
|
4438
|
+
postCommitPath: relativePostCommitHookPath,
|
|
4439
|
+
status: wroteSomething ? "written" : "exists",
|
|
4440
|
+
written: wroteSomething,
|
|
4441
|
+
overwritten: false,
|
|
4442
|
+
preCommitAction,
|
|
4443
|
+
postCommitAction,
|
|
4444
|
+
nextSteps: [
|
|
4445
|
+
"Commit normally. Ripple will check staged changes before each commit.",
|
|
4446
|
+
"After a successful commit, Ripple clears consumed local intents to prevent ghost-intent blocks.",
|
|
4447
|
+
"Humans can intentionally bypass local hooks with git commit --no-verify.",
|
|
4448
|
+
],
|
|
4449
|
+
};
|
|
4450
|
+
if (options.json) {
|
|
4451
|
+
printJson(summary);
|
|
4452
|
+
}
|
|
4453
|
+
else {
|
|
4454
|
+
console.log(wroteSomething ? "Ripple Git hooks integrated" : "Ripple Git hooks already integrated");
|
|
4455
|
+
console.log(`Pre-commit: ${relativeHookPath} (${preCommitAction})`);
|
|
4456
|
+
console.log(`Post-commit: ${relativePostCommitHookPath} (${postCommitAction})`);
|
|
4457
|
+
console.log("Active intent: blocks staged drift against latest local plan.");
|
|
4458
|
+
console.log("No intent: warns with staged policy/contract awareness and lets humans stay in control.");
|
|
4459
|
+
console.log("Post-commit: clears consumed local intents to prevent ghost-intent blocks.");
|
|
4460
|
+
}
|
|
4461
|
+
}
|
|
2893
4462
|
function initCiCommand(options) {
|
|
2894
4463
|
const workflow = githubActionsWorkflow();
|
|
2895
4464
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
@@ -2924,7 +4493,7 @@ function initCiCommand(options) {
|
|
|
2924
4493
|
}
|
|
2925
4494
|
console.log(existed ? "Ripple CI workflow overwritten" : "Ripple CI workflow written");
|
|
2926
4495
|
console.log(`Path: ${relativeTargetPath}`);
|
|
2927
|
-
console.log(
|
|
4496
|
+
console.log(`Command: npx -y ${rippleCliPackageSpec()} ci --base origin/\${{ github.base_ref }} --github-annotations`);
|
|
2928
4497
|
}
|
|
2929
4498
|
function policyCommand(args, options) {
|
|
2930
4499
|
const subcommand = args[0];
|
|
@@ -2932,15 +4501,19 @@ function policyCommand(args, options) {
|
|
|
2932
4501
|
policyInitCommand(options);
|
|
2933
4502
|
return;
|
|
2934
4503
|
}
|
|
4504
|
+
if (subcommand === "sync") {
|
|
4505
|
+
policySyncCommand(options);
|
|
4506
|
+
return;
|
|
4507
|
+
}
|
|
2935
4508
|
if (subcommand === "explain") {
|
|
2936
4509
|
policyExplainCommand(options);
|
|
2937
4510
|
return;
|
|
2938
4511
|
}
|
|
2939
|
-
throw new Error("Usage: ripple policy init [--print] [--force] or ripple policy explain --file <file>");
|
|
4512
|
+
throw new Error("Usage: ripple policy init [--print] [--force], ripple policy sync [--json], or ripple policy explain --file <file>");
|
|
2940
4513
|
}
|
|
2941
4514
|
function policyInitCommand(options) {
|
|
2942
|
-
const policy = (0, core_1.defaultRipplePolicy)();
|
|
2943
4515
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
4516
|
+
const { policy, detections } = (0, core_1.buildSmartRipplePolicy)(workspaceRoot);
|
|
2944
4517
|
const targetPath = (0, core_1.ripplePolicyPath)(workspaceRoot);
|
|
2945
4518
|
const relativeTargetPath = core_1.RIPPLE_POLICY_PATH.split(path.sep).join("/");
|
|
2946
4519
|
const contents = (0, core_1.formatRipplePolicy)(policy);
|
|
@@ -2949,6 +4522,7 @@ function policyInitCommand(options) {
|
|
|
2949
4522
|
printJson({
|
|
2950
4523
|
path: relativeTargetPath,
|
|
2951
4524
|
policy,
|
|
4525
|
+
detections,
|
|
2952
4526
|
written: false,
|
|
2953
4527
|
});
|
|
2954
4528
|
}
|
|
@@ -2969,6 +4543,7 @@ function policyInitCommand(options) {
|
|
|
2969
4543
|
written: true,
|
|
2970
4544
|
overwritten: existed,
|
|
2971
4545
|
policy,
|
|
4546
|
+
detections,
|
|
2972
4547
|
});
|
|
2973
4548
|
return;
|
|
2974
4549
|
}
|
|
@@ -2976,6 +4551,189 @@ function policyInitCommand(options) {
|
|
|
2976
4551
|
console.log(`Path: ${relativeTargetPath}`);
|
|
2977
4552
|
console.log(`Default mode: ${policy.defaultMode ?? "file"}`);
|
|
2978
4553
|
console.log(`Risk rules: ${policy.riskRules?.length ?? 0}`);
|
|
4554
|
+
if (detections.length > 0) {
|
|
4555
|
+
console.log("Smart detections:");
|
|
4556
|
+
detections.forEach((detection) => {
|
|
4557
|
+
console.log(`- ${detection.kind}: ${detection.evidence.join(", ")}`);
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
function policySyncCommand(options) {
|
|
4562
|
+
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
4563
|
+
const summary = buildPolicySyncSummary(workspaceRoot);
|
|
4564
|
+
if (options.json) {
|
|
4565
|
+
printJson(summary);
|
|
4566
|
+
return;
|
|
4567
|
+
}
|
|
4568
|
+
printPolicySyncSummary(summary);
|
|
4569
|
+
}
|
|
4570
|
+
function buildPolicySyncSummary(workspaceRoot) {
|
|
4571
|
+
const loadedPolicy = (0, core_1.loadRipplePolicy)(workspaceRoot);
|
|
4572
|
+
const { policy: smartPolicy, detections } = (0, core_1.buildSmartRipplePolicy)(workspaceRoot);
|
|
4573
|
+
const existingRules = loadedPolicy.policy.riskRules ?? [];
|
|
4574
|
+
const missingRules = [];
|
|
4575
|
+
const missingRuleKeys = new Set();
|
|
4576
|
+
const detectionSummaries = detections.map((detection) => {
|
|
4577
|
+
let missingForDetection = 0;
|
|
4578
|
+
detection.rules.forEach((rule) => {
|
|
4579
|
+
if (policyRuleIsCovered(rule, existingRules)) {
|
|
4580
|
+
return;
|
|
4581
|
+
}
|
|
4582
|
+
const key = policyRuleKey(rule);
|
|
4583
|
+
if (missingRuleKeys.has(key)) {
|
|
4584
|
+
return;
|
|
4585
|
+
}
|
|
4586
|
+
missingRuleKeys.add(key);
|
|
4587
|
+
missingForDetection += 1;
|
|
4588
|
+
missingRules.push({
|
|
4589
|
+
...clonePolicyRule(rule),
|
|
4590
|
+
reason: `${detection.kind}: ${detection.evidence.join(", ")}`,
|
|
4591
|
+
});
|
|
4592
|
+
});
|
|
4593
|
+
return {
|
|
4594
|
+
kind: detection.kind,
|
|
4595
|
+
evidence: detection.evidence,
|
|
4596
|
+
missingRules: missingForDetection,
|
|
4597
|
+
};
|
|
4598
|
+
});
|
|
4599
|
+
if (!loadedPolicy.exists) {
|
|
4600
|
+
(smartPolicy.riskRules ?? []).forEach((rule) => {
|
|
4601
|
+
const key = policyRuleKey(rule);
|
|
4602
|
+
if (missingRuleKeys.has(key)) {
|
|
4603
|
+
return;
|
|
4604
|
+
}
|
|
4605
|
+
missingRuleKeys.add(key);
|
|
4606
|
+
missingRules.push({
|
|
4607
|
+
...clonePolicyRule(rule),
|
|
4608
|
+
reason: "policy file missing",
|
|
4609
|
+
});
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
const status = missingRules.length > 0 ? "update-available" : "up-to-date";
|
|
4613
|
+
return {
|
|
4614
|
+
protocol: "ripple-policy-sync",
|
|
4615
|
+
version: 1,
|
|
4616
|
+
workspace: workspaceRoot,
|
|
4617
|
+
policyPath: core_1.RIPPLE_POLICY_PATH.split(path.sep).join("/"),
|
|
4618
|
+
policyExists: loadedPolicy.exists,
|
|
4619
|
+
status,
|
|
4620
|
+
missingRules,
|
|
4621
|
+
detections: detectionSummaries,
|
|
4622
|
+
nextSteps: policySyncNextSteps(status, loadedPolicy.exists),
|
|
4623
|
+
};
|
|
4624
|
+
}
|
|
4625
|
+
function clonePolicyRule(rule) {
|
|
4626
|
+
return {
|
|
4627
|
+
paths: [...rule.paths],
|
|
4628
|
+
...(rule.risk ? { risk: rule.risk } : {}),
|
|
4629
|
+
...(rule.requireHumanBeforeEdit === true ? { requireHumanBeforeEdit: true } : {}),
|
|
4630
|
+
...(rule.requireHumanBeforeMerge === true ? { requireHumanBeforeMerge: true } : {}),
|
|
4631
|
+
...(rule.allowPrMode === true ? { allowPrMode: true } : {}),
|
|
4632
|
+
};
|
|
4633
|
+
}
|
|
4634
|
+
function policyRuleIsCovered(suggested, existingRules) {
|
|
4635
|
+
return existingRules.some((existing) => {
|
|
4636
|
+
if (!suggested.paths.every((suggestedPath) => existing.paths.some((existingPath) => policyPathPatternCovers(existingPath, suggestedPath)))) {
|
|
4637
|
+
return false;
|
|
4638
|
+
}
|
|
4639
|
+
if (suggested.risk && comparePolicyRisk(existing.risk, suggested.risk) < 0) {
|
|
4640
|
+
return false;
|
|
4641
|
+
}
|
|
4642
|
+
if (suggested.requireHumanBeforeEdit === true && existing.requireHumanBeforeEdit !== true) {
|
|
4643
|
+
return false;
|
|
4644
|
+
}
|
|
4645
|
+
if (suggested.requireHumanBeforeMerge === true && existing.requireHumanBeforeMerge !== true) {
|
|
4646
|
+
return false;
|
|
4647
|
+
}
|
|
4648
|
+
return true;
|
|
4649
|
+
});
|
|
4650
|
+
}
|
|
4651
|
+
function policyPathPatternCovers(existingPattern, suggestedPattern) {
|
|
4652
|
+
const existing = normalizePolicyPattern(existingPattern);
|
|
4653
|
+
const suggested = normalizePolicyPattern(suggestedPattern);
|
|
4654
|
+
if (existing === suggested) {
|
|
4655
|
+
return true;
|
|
4656
|
+
}
|
|
4657
|
+
if (existing === "**" || existing === "**/*") {
|
|
4658
|
+
return true;
|
|
4659
|
+
}
|
|
4660
|
+
if (existing.endsWith("/**")) {
|
|
4661
|
+
const base = existing.slice(0, -3);
|
|
4662
|
+
return suggested === base || suggested.startsWith(`${base}/`);
|
|
4663
|
+
}
|
|
4664
|
+
if (!suggested.includes("*") && policyGlobToRegExp(existing).test(suggested)) {
|
|
4665
|
+
return true;
|
|
4666
|
+
}
|
|
4667
|
+
return false;
|
|
4668
|
+
}
|
|
4669
|
+
function normalizePolicyPattern(pattern) {
|
|
4670
|
+
return pattern.replace(/\\\\/g, "/").replace(/^\.\//, "");
|
|
4671
|
+
}
|
|
4672
|
+
function policyGlobToRegExp(pattern) {
|
|
4673
|
+
const normalized = normalizePolicyPattern(pattern);
|
|
4674
|
+
let source = "";
|
|
4675
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
4676
|
+
const char = normalized[index];
|
|
4677
|
+
const next = normalized[index + 1];
|
|
4678
|
+
if (char === "*" && next === "*") {
|
|
4679
|
+
source += ".*";
|
|
4680
|
+
index += 1;
|
|
4681
|
+
continue;
|
|
4682
|
+
}
|
|
4683
|
+
if (char === "*") {
|
|
4684
|
+
source += "[^/]*";
|
|
4685
|
+
continue;
|
|
4686
|
+
}
|
|
4687
|
+
source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
4688
|
+
}
|
|
4689
|
+
return new RegExp(`^${source}$`);
|
|
4690
|
+
}
|
|
4691
|
+
function comparePolicyRisk(existing, suggested) {
|
|
4692
|
+
const levels = ["low", "medium", "high", "critical"];
|
|
4693
|
+
return levels.indexOf(existing ?? "low") - levels.indexOf(suggested ?? "low");
|
|
4694
|
+
}
|
|
4695
|
+
function policyRuleKey(rule) {
|
|
4696
|
+
return JSON.stringify({
|
|
4697
|
+
paths: rule.paths.map(normalizePolicyPattern).sort(),
|
|
4698
|
+
risk: rule.risk ?? "",
|
|
4699
|
+
requireHumanBeforeEdit: rule.requireHumanBeforeEdit === true,
|
|
4700
|
+
requireHumanBeforeMerge: rule.requireHumanBeforeMerge === true,
|
|
4701
|
+
allowPrMode: rule.allowPrMode === true,
|
|
4702
|
+
});
|
|
4703
|
+
}
|
|
4704
|
+
function policySyncNextSteps(status, policyExists) {
|
|
4705
|
+
if (!policyExists) {
|
|
4706
|
+
return [
|
|
4707
|
+
"Run ripple policy init to create .ripple/policy.json from the current repository shape.",
|
|
4708
|
+
"Review the suggested risk rules before committing the policy.",
|
|
4709
|
+
];
|
|
4710
|
+
}
|
|
4711
|
+
if (status === "up-to-date") {
|
|
4712
|
+
return ["No policy update is required right now."];
|
|
4713
|
+
}
|
|
4714
|
+
return [
|
|
4715
|
+
"Review the suggested missing rules with a human maintainer.",
|
|
4716
|
+
"Update .ripple/policy.json only after approving the new trust boundaries.",
|
|
4717
|
+
];
|
|
4718
|
+
}
|
|
4719
|
+
function printPolicySyncSummary(summary) {
|
|
4720
|
+
console.log("Ripple policy sync");
|
|
4721
|
+
console.log(`Policy: ${summary.policyPath}${summary.policyExists ? "" : " (missing)"}`);
|
|
4722
|
+
console.log(`Status: ${summary.status}`);
|
|
4723
|
+
if (summary.missingRules.length > 0) {
|
|
4724
|
+
console.log("");
|
|
4725
|
+
console.log("Detected risky paths not covered by policy:");
|
|
4726
|
+
summary.missingRules.forEach((rule) => {
|
|
4727
|
+
console.log(`- ${rule.paths.join(", ")} risk=${rule.risk ?? "medium"}${rule.requireHumanBeforeEdit ? " human-before-edit" : ""}${rule.requireHumanBeforeMerge ? " human-before-merge" : ""}`);
|
|
4728
|
+
console.log(` reason: ${rule.reason}`);
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4731
|
+
else {
|
|
4732
|
+
console.log("Policy is up to date with current smart detections.");
|
|
4733
|
+
}
|
|
4734
|
+
console.log("");
|
|
4735
|
+
console.log("Next:");
|
|
4736
|
+
summary.nextSteps.forEach((step) => console.log(`- ${step}`));
|
|
2979
4737
|
}
|
|
2980
4738
|
function policyExplainCommand(options) {
|
|
2981
4739
|
if (!options.file) {
|
|
@@ -3035,8 +4793,8 @@ function printAgentPolicyExplanation(explanation) {
|
|
|
3035
4793
|
}
|
|
3036
4794
|
async function repairCommand(options) {
|
|
3037
4795
|
const workspaceRoot = resolveWorkspaceRoot(".");
|
|
3038
|
-
const stagedFiles = (0, core_1.listGitStagedFiles)(workspaceRoot);
|
|
3039
4796
|
const intent = (0, core_1.loadChangeIntent)(workspaceRoot, options.intent ?? "latest");
|
|
4797
|
+
const stagedFiles = (0, core_1.listGitStagedFiles)(workspaceRoot);
|
|
3040
4798
|
const engine = createFastCheckEngine(workspaceRoot);
|
|
3041
4799
|
try {
|
|
3042
4800
|
await runWithQuietEngine(() => engine.fastCheckScan(fastCheckCandidateFiles(stagedFiles, intent)));
|
|
@@ -3299,6 +5057,13 @@ async function main() {
|
|
|
3299
5057
|
return;
|
|
3300
5058
|
}
|
|
3301
5059
|
if (command === "agent") {
|
|
5060
|
+
if (arg === "setup") {
|
|
5061
|
+
agentSetupCommand(options);
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
if (arg && arg !== "setup") {
|
|
5065
|
+
throw new Error("Usage: ripple agent or ripple agent setup [--print] [--force]");
|
|
5066
|
+
}
|
|
3302
5067
|
if (options.json) {
|
|
3303
5068
|
printJson((0, core_1.getAgentWorkflowSummary)());
|
|
3304
5069
|
}
|
|
@@ -3307,6 +5072,10 @@ async function main() {
|
|
|
3307
5072
|
}
|
|
3308
5073
|
return;
|
|
3309
5074
|
}
|
|
5075
|
+
if (command === "hook") {
|
|
5076
|
+
hookInstallCommand(arg, options);
|
|
5077
|
+
return;
|
|
5078
|
+
}
|
|
3310
5079
|
if (command === "init") {
|
|
3311
5080
|
await initCommand(options);
|
|
3312
5081
|
return;
|
|
@@ -3355,6 +5124,10 @@ async function main() {
|
|
|
3355
5124
|
await planCommand(options);
|
|
3356
5125
|
return;
|
|
3357
5126
|
}
|
|
5127
|
+
if (command === "intent") {
|
|
5128
|
+
intentCommand(arg, options);
|
|
5129
|
+
return;
|
|
5130
|
+
}
|
|
3358
5131
|
if (command === "check") {
|
|
3359
5132
|
await checkCommand(options);
|
|
3360
5133
|
return;
|
|
@@ -3367,6 +5140,10 @@ async function main() {
|
|
|
3367
5140
|
await gateCommand(options);
|
|
3368
5141
|
return;
|
|
3369
5142
|
}
|
|
5143
|
+
if (command === "verify") {
|
|
5144
|
+
verifyCommand(options);
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
3370
5147
|
if (command === "approval") {
|
|
3371
5148
|
approvalCommand(options);
|
|
3372
5149
|
return;
|