@getripple/core 1.0.4
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 +18 -0
- package/README.md +53 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.js +396 -0
- package/dist/adapters.js.map +1 -0
- package/dist/agent-workflow.d.ts +86 -0
- package/dist/agent-workflow.js +404 -0
- package/dist/agent-workflow.js.map +1 -0
- package/dist/approval.d.ts +45 -0
- package/dist/approval.js +272 -0
- package/dist/approval.js.map +1 -0
- package/dist/audit.d.ts +80 -0
- package/dist/audit.js +271 -0
- package/dist/audit.js.map +1 -0
- package/dist/change-intent.d.ts +242 -0
- package/dist/change-intent.js +1758 -0
- package/dist/change-intent.js.map +1 -0
- package/dist/graph.d.ts +346 -0
- package/dist/graph.js +4221 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/normalizer.d.ts +34 -0
- package/dist/normalizer.js +455 -0
- package/dist/normalizer.js.map +1 -0
- package/dist/policy.d.ts +55 -0
- package/dist/policy.js +380 -0
- package/dist/policy.js.map +1 -0
- package/dist/readiness.d.ts +35 -0
- package/dist/readiness.js +200 -0
- package/dist/readiness.js.map +1 -0
- package/dist/staged-check.d.ts +96 -0
- package/dist/staged-check.js +853 -0
- package/dist/staged-check.js.map +1 -0
- package/dist/types.d.ts +122 -0
- package/dist/types.js +71 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1758 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.defaultChangeIntentPath = exports.loadChangeIntent = exports.saveChangeIntent = exports.buildIntentDriftRepairPlan = exports.validateStagedCheckAgainstIntent = exports.buildAgentHandoffVerdict = exports.buildChangeIntentReadinessSnapshot = exports.buildChangeIntent = void 0;
|
|
27
|
+
const crypto = __importStar(require("crypto"));
|
|
28
|
+
const fs = __importStar(require("fs"));
|
|
29
|
+
const path = __importStar(require("path"));
|
|
30
|
+
const INTENT_PROTOCOL = "ripple-change-intent";
|
|
31
|
+
const INTENT_VERSION = 1;
|
|
32
|
+
const INTENTS_DIR = path.join(".ripple", "intents");
|
|
33
|
+
const LATEST_INTENT_FILE = "latest.json";
|
|
34
|
+
const SOURCE_FILE_RE = /\.(ts|tsx|js|jsx|py)$/i;
|
|
35
|
+
function buildChangeIntent(plan, options = {}) {
|
|
36
|
+
const controlMode = options.controlMode ?? options.policy?.defaultMode ?? "file";
|
|
37
|
+
assertControlMode(controlMode);
|
|
38
|
+
const editableFiles = editableFilesForControlMode(plan, controlMode, options);
|
|
39
|
+
const contextFiles = uniqueItems([
|
|
40
|
+
...plan.readFirst.map((file) => file.file),
|
|
41
|
+
...plan.readIfNeeded.map((file) => file.file),
|
|
42
|
+
...plan.verificationTargets.filter(isSourceFilePath),
|
|
43
|
+
].filter((file) => !editableFiles.includes(file)));
|
|
44
|
+
const allowedFiles = uniqueItems([...editableFiles, ...contextFiles]);
|
|
45
|
+
const allowedSymbols = allowedSymbolsForControlMode(plan, controlMode, options);
|
|
46
|
+
const expectedSymbols = uniqueItems(allowedSymbols.length > 0
|
|
47
|
+
? allowedSymbols
|
|
48
|
+
: plan.symbolFocus
|
|
49
|
+
.filter((symbol) => symbol.file === plan.targetFile || symbol.signals.includes("task-match"))
|
|
50
|
+
.map((symbol) => symbol.symbol));
|
|
51
|
+
const protectedContracts = uniqueItems(plan.symbolFocus
|
|
52
|
+
.filter((symbol) => symbol.callers > 0 || symbol.file === plan.targetFile)
|
|
53
|
+
.map((symbol) => symbol.symbol));
|
|
54
|
+
const boundaryRisk = strongestBoundaryRisk(controlBoundaryRisk(plan), options.policy?.risk);
|
|
55
|
+
const humanGate = humanGateForPlan(plan, controlMode, boundaryRisk, options.policy);
|
|
56
|
+
const humanGateReason = humanGateReasons(plan, controlMode, boundaryRisk, options.policy);
|
|
57
|
+
const policySource = policySourceLabel(options.policy);
|
|
58
|
+
const policyMatches = options.policy?.matchedRules ?? [];
|
|
59
|
+
const policyExplanation = normalizePolicyExplanationSnapshot(options.policyExplanation, {
|
|
60
|
+
targetFile: plan.targetFile,
|
|
61
|
+
controlMode,
|
|
62
|
+
boundaryRisk,
|
|
63
|
+
humanGate,
|
|
64
|
+
policySource,
|
|
65
|
+
policyMatches,
|
|
66
|
+
policyRisk: options.policy?.risk ?? "none",
|
|
67
|
+
});
|
|
68
|
+
const createdAt = new Date().toISOString();
|
|
69
|
+
return {
|
|
70
|
+
protocol: INTENT_PROTOCOL,
|
|
71
|
+
version: INTENT_VERSION,
|
|
72
|
+
id: makeIntentId(plan, createdAt),
|
|
73
|
+
createdAt,
|
|
74
|
+
task: plan.task,
|
|
75
|
+
targetFile: plan.targetFile,
|
|
76
|
+
risk: plan.risk,
|
|
77
|
+
tokenBudget: plan.tokenBudget,
|
|
78
|
+
controlMode,
|
|
79
|
+
allowedSymbols,
|
|
80
|
+
humanGate,
|
|
81
|
+
humanGateReason,
|
|
82
|
+
boundaryRisk,
|
|
83
|
+
policySource,
|
|
84
|
+
policyMatches,
|
|
85
|
+
policyExplanation,
|
|
86
|
+
editableFiles,
|
|
87
|
+
contextFiles,
|
|
88
|
+
allowedFiles,
|
|
89
|
+
expectedFiles: editableFiles,
|
|
90
|
+
expectedSymbols,
|
|
91
|
+
protectedContracts,
|
|
92
|
+
verificationTargets: plan.verificationTargets,
|
|
93
|
+
readinessSnapshot: options.readinessSnapshot ?? fallbackReadinessSnapshot(),
|
|
94
|
+
why: plan.why,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
exports.buildChangeIntent = buildChangeIntent;
|
|
98
|
+
function buildChangeIntentReadinessSnapshot(readiness) {
|
|
99
|
+
return {
|
|
100
|
+
status: readiness.status,
|
|
101
|
+
enforcementLevel: readiness.enforcement.level,
|
|
102
|
+
canGuideAgents: readiness.enforcement.canGuideAgents,
|
|
103
|
+
canDetectDrift: readiness.enforcement.canDetectDrift,
|
|
104
|
+
canBlockInCi: readiness.enforcement.canBlockInCi,
|
|
105
|
+
policyExplicit: readiness.enforcement.explicitPolicy.ok,
|
|
106
|
+
graphOk: readiness.checks.graph.ok,
|
|
107
|
+
gitOk: readiness.checks.git.ok,
|
|
108
|
+
ciWorkflowOk: readiness.checks.ciWorkflow.ok,
|
|
109
|
+
latestIntentOk: readiness.checks.latestIntent.ok,
|
|
110
|
+
gaps: readiness.enforcement.gaps,
|
|
111
|
+
nextSteps: readiness.nextSteps,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
exports.buildChangeIntentReadinessSnapshot = buildChangeIntentReadinessSnapshot;
|
|
115
|
+
function buildAgentHandoffVerdict(input) {
|
|
116
|
+
return {
|
|
117
|
+
protocol: "ripple-agent-handoff",
|
|
118
|
+
version: 1,
|
|
119
|
+
source: input.source,
|
|
120
|
+
canContinue: input.canContinue,
|
|
121
|
+
mustStop: input.mustStop ?? !input.canContinue,
|
|
122
|
+
needsHuman: input.needsHuman,
|
|
123
|
+
decision: input.decision,
|
|
124
|
+
nextRequiredPhase: input.nextRequiredPhase,
|
|
125
|
+
nextRequiredAction: input.nextRequiredAction,
|
|
126
|
+
summary: input.summary,
|
|
127
|
+
why: uniqueItems(input.why),
|
|
128
|
+
fixNow: uniqueItems(input.fixNow),
|
|
129
|
+
askHuman: uniqueItems(input.askHuman ?? []),
|
|
130
|
+
commands: {
|
|
131
|
+
doctor: uniqueItems(input.commands?.doctor ?? []),
|
|
132
|
+
plan: uniqueItems(input.commands?.plan ?? []),
|
|
133
|
+
check: uniqueItems(input.commands?.check ?? []),
|
|
134
|
+
audit: uniqueItems(input.commands?.audit ?? []),
|
|
135
|
+
repair: uniqueItems(input.commands?.repair ?? []),
|
|
136
|
+
approve: uniqueItems(input.commands?.approve ?? []),
|
|
137
|
+
unstage: uniqueItems(input.commands?.unstage ?? []),
|
|
138
|
+
verify: uniqueItems(input.commands?.verify ?? []),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
exports.buildAgentHandoffVerdict = buildAgentHandoffVerdict;
|
|
143
|
+
function validateStagedCheckAgainstIntent(staged, intent, options = {}) {
|
|
144
|
+
const validation = buildIntentValidation(staged, intent, options);
|
|
145
|
+
return {
|
|
146
|
+
...staged,
|
|
147
|
+
requiresAttention: staged.requiresAttention || validation.requiresAttention,
|
|
148
|
+
intentValidation: validation,
|
|
149
|
+
nextRequiredPhase: validation.nextRequiredPhase,
|
|
150
|
+
nextRequiredAction: validation.nextRequiredAction,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
exports.validateStagedCheckAgainstIntent = validateStagedCheckAgainstIntent;
|
|
154
|
+
function buildIntentDriftRepairPlan(staged) {
|
|
155
|
+
const validation = staged.intentValidation;
|
|
156
|
+
const verificationTargets = uniqueItems(staged.files.flatMap((file) => file.verificationTargets));
|
|
157
|
+
if (!validation) {
|
|
158
|
+
const missingIntentPlan = {
|
|
159
|
+
protocol: "ripple-intent-drift-repair",
|
|
160
|
+
version: 1,
|
|
161
|
+
verdict: "missing-intent",
|
|
162
|
+
driftVerdict: missingIntentDriftVerdict(),
|
|
163
|
+
status: "intent-required",
|
|
164
|
+
summary: "No saved change intent was provided, so Ripple cannot repair plan drift yet.",
|
|
165
|
+
recommendedAction: "Run staged check with a saved intent before asking Ripple to repair drift.",
|
|
166
|
+
blockingReasons: ["Missing intent validation"],
|
|
167
|
+
unstageFiles: [],
|
|
168
|
+
reviewContracts: [],
|
|
169
|
+
createNewIntent: false,
|
|
170
|
+
verificationTargets,
|
|
171
|
+
fixActions: missingIntentRepairActions(),
|
|
172
|
+
agentActions: staged.agentActions,
|
|
173
|
+
commands: {
|
|
174
|
+
unstage: [],
|
|
175
|
+
replan: ["Run ripple_plan_context with saveIntent: true, then run ripple_check_staged with intentPath."],
|
|
176
|
+
verify: verificationTargets,
|
|
177
|
+
},
|
|
178
|
+
nextSteps: [
|
|
179
|
+
"Create or load a saved change intent.",
|
|
180
|
+
"Run staged check against that intent.",
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
...missingIntentPlan,
|
|
185
|
+
handoff: buildRepairHandoff(missingIntentPlan),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const reviewContracts = uniqueItems([
|
|
189
|
+
...validation.protectedContractChanges,
|
|
190
|
+
...validation.unplannedContractChanges,
|
|
191
|
+
]);
|
|
192
|
+
const unstageFiles = validation.driftVerdict.status === "pass"
|
|
193
|
+
? []
|
|
194
|
+
: uniqueItems([
|
|
195
|
+
...validation.unplannedFiles,
|
|
196
|
+
...validation.boundaryVerdict.changedOutsideBoundaryFiles,
|
|
197
|
+
]);
|
|
198
|
+
const fixActions = buildRepairActions({
|
|
199
|
+
validation,
|
|
200
|
+
unstageFiles,
|
|
201
|
+
reviewContracts,
|
|
202
|
+
verificationTargets,
|
|
203
|
+
});
|
|
204
|
+
const repairPlan = {
|
|
205
|
+
protocol: "ripple-intent-drift-repair",
|
|
206
|
+
version: 1,
|
|
207
|
+
intentId: validation.intentId,
|
|
208
|
+
verdict: validation.verdict,
|
|
209
|
+
driftVerdict: validation.driftVerdict,
|
|
210
|
+
boundaryVerdict: validation.boundaryVerdict,
|
|
211
|
+
policyExplanation: validation.policyExplanation,
|
|
212
|
+
policyDrift: validation.policyDrift,
|
|
213
|
+
readinessDrift: validation.readinessDrift,
|
|
214
|
+
status: repairStatus(validation),
|
|
215
|
+
summary: repairSummary(validation),
|
|
216
|
+
recommendedAction: validation.recommendedAction,
|
|
217
|
+
blockingReasons: validation.blockingReasons,
|
|
218
|
+
unstageFiles,
|
|
219
|
+
reviewContracts,
|
|
220
|
+
createNewIntent: validation.driftVerdict.status !== "pass",
|
|
221
|
+
verificationTargets,
|
|
222
|
+
fixActions,
|
|
223
|
+
agentActions: staged.agentActions,
|
|
224
|
+
commands: {
|
|
225
|
+
unstage: unstageFiles.map((file) => `git restore --staged -- ${file}`),
|
|
226
|
+
replan: validation.driftVerdict.status === "pass"
|
|
227
|
+
? []
|
|
228
|
+
: ["Run ripple_plan_context with saveIntent: true for the broader intended scope."],
|
|
229
|
+
verify: verificationTargets,
|
|
230
|
+
},
|
|
231
|
+
nextSteps: validation.nextSteps,
|
|
232
|
+
};
|
|
233
|
+
return {
|
|
234
|
+
...repairPlan,
|
|
235
|
+
handoff: buildRepairHandoff(repairPlan),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
exports.buildIntentDriftRepairPlan = buildIntentDriftRepairPlan;
|
|
239
|
+
function missingIntentDriftVerdict() {
|
|
240
|
+
return {
|
|
241
|
+
status: "unknown",
|
|
242
|
+
decision: "create-intent-first",
|
|
243
|
+
label: "UNKNOWN",
|
|
244
|
+
summary: "UNKNOWN: no saved change intent is available, so Ripple cannot judge drift.",
|
|
245
|
+
why: ["No saved change intent was provided for comparison."],
|
|
246
|
+
fix: [
|
|
247
|
+
"Run ripple plan --file <file> --task \"<task>\" --agent --save.",
|
|
248
|
+
"Stage the intended files, then run ripple check --staged --agent --intent latest.",
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function missingIntentRepairActions() {
|
|
253
|
+
return [
|
|
254
|
+
{
|
|
255
|
+
type: "create-intent",
|
|
256
|
+
priority: "blocker",
|
|
257
|
+
command: "ripple plan --file <file> --task \"<task>\" --agent --save",
|
|
258
|
+
reason: "No saved change intent was available for drift comparison.",
|
|
259
|
+
instruction: "Create a saved change intent before relying on Ripple to judge whether edits stayed in scope.",
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
function buildRepairHandoff(plan) {
|
|
264
|
+
const canContinue = plan.status === "no-repair-needed";
|
|
265
|
+
const needsHuman = plan.status === "human-review-required" ||
|
|
266
|
+
plan.status === "contract-review-required";
|
|
267
|
+
const nextRequiredPhase = repairHandoffNextRequiredPhase(plan);
|
|
268
|
+
const nextRequiredAction = repairHandoffNextRequiredAction(plan, nextRequiredPhase);
|
|
269
|
+
return buildAgentHandoffVerdict({
|
|
270
|
+
source: "repair",
|
|
271
|
+
canContinue,
|
|
272
|
+
needsHuman,
|
|
273
|
+
decision: repairHandoffDecision(plan, canContinue, needsHuman),
|
|
274
|
+
nextRequiredPhase,
|
|
275
|
+
nextRequiredAction,
|
|
276
|
+
summary: plan.summary,
|
|
277
|
+
why: plan.blockingReasons.length > 0 ? plan.blockingReasons : plan.driftVerdict.why,
|
|
278
|
+
fixNow: repairHandoffFixNow(plan),
|
|
279
|
+
askHuman: repairHandoffAskHuman(plan),
|
|
280
|
+
commands: {
|
|
281
|
+
doctor: plan.readinessDrift?.status === "weakened"
|
|
282
|
+
? ["ripple doctor --agent --strict"]
|
|
283
|
+
: [],
|
|
284
|
+
plan: plan.commands.replan,
|
|
285
|
+
check: plan.status === "intent-required"
|
|
286
|
+
? ["ripple check --staged --agent --intent latest"]
|
|
287
|
+
: [],
|
|
288
|
+
audit: canContinue ? ["ripple audit --agent --intent latest"] : [],
|
|
289
|
+
repair: canContinue ? [] : ["ripple repair --agent --intent latest"],
|
|
290
|
+
approve: plan.boundaryVerdict?.humanRequired
|
|
291
|
+
? [
|
|
292
|
+
"ripple approval --intent latest --agent",
|
|
293
|
+
`ripple approve --intent latest --gate ${approvalGateForHumanGate(plan.boundaryVerdict.humanGate)}`,
|
|
294
|
+
]
|
|
295
|
+
: [],
|
|
296
|
+
unstage: plan.commands.unstage,
|
|
297
|
+
verify: plan.commands.verify,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
function repairHandoffDecision(plan, canContinue, needsHuman) {
|
|
302
|
+
if (plan.status === "intent-required") {
|
|
303
|
+
return "create-intent";
|
|
304
|
+
}
|
|
305
|
+
if (plan.readinessDrift?.status === "weakened") {
|
|
306
|
+
return "restore-readiness";
|
|
307
|
+
}
|
|
308
|
+
if (needsHuman) {
|
|
309
|
+
return "human-review";
|
|
310
|
+
}
|
|
311
|
+
if (!canContinue) {
|
|
312
|
+
return "repair";
|
|
313
|
+
}
|
|
314
|
+
return "audit";
|
|
315
|
+
}
|
|
316
|
+
function repairHandoffNextRequiredPhase(plan) {
|
|
317
|
+
if (plan.status === "intent-required") {
|
|
318
|
+
return "plan_before_edit";
|
|
319
|
+
}
|
|
320
|
+
if (plan.status === "no-repair-needed") {
|
|
321
|
+
return "audit_after_change";
|
|
322
|
+
}
|
|
323
|
+
return "repair_or_handoff";
|
|
324
|
+
}
|
|
325
|
+
function repairHandoffNextRequiredAction(plan, phase) {
|
|
326
|
+
if (phase === "plan_before_edit") {
|
|
327
|
+
return "Create a saved Ripple plan, then run staged check against that intent.";
|
|
328
|
+
}
|
|
329
|
+
if (phase === "audit_after_change") {
|
|
330
|
+
return "Run ripple audit --agent --intent latest before final handoff.";
|
|
331
|
+
}
|
|
332
|
+
if (plan.readinessDrift?.status === "weakened") {
|
|
333
|
+
return "Restore Ripple readiness with the listed commands or ask the human before continuing.";
|
|
334
|
+
}
|
|
335
|
+
if (plan.status === "human-review-required" || plan.status === "contract-review-required") {
|
|
336
|
+
return "Ask the human to review the blockers before keeping this change.";
|
|
337
|
+
}
|
|
338
|
+
return "Apply the repair actions, then rerun ripple check --staged --agent --intent latest.";
|
|
339
|
+
}
|
|
340
|
+
function repairHandoffFixNow(plan) {
|
|
341
|
+
if (plan.status === "no-repair-needed") {
|
|
342
|
+
return plan.verificationTargets.length > 0
|
|
343
|
+
? plan.verificationTargets.map((target) => `Verify before handoff: ${target}`)
|
|
344
|
+
: plan.nextSteps;
|
|
345
|
+
}
|
|
346
|
+
return uniqueItems([
|
|
347
|
+
...plan.fixActions
|
|
348
|
+
.filter((action) => action.priority === "blocker" || action.priority === "required")
|
|
349
|
+
.map((action) => action.instruction),
|
|
350
|
+
...plan.nextSteps,
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
function repairHandoffAskHuman(plan) {
|
|
354
|
+
const askHuman = [];
|
|
355
|
+
if (plan.status === "human-review-required") {
|
|
356
|
+
askHuman.push(plan.summary);
|
|
357
|
+
}
|
|
358
|
+
if (plan.status === "contract-review-required") {
|
|
359
|
+
askHuman.push("Review protected or unplanned contract changes before keeping this change.");
|
|
360
|
+
}
|
|
361
|
+
if (plan.readinessDrift?.status === "weakened") {
|
|
362
|
+
askHuman.push("Approve continuing only if weaker Ripple readiness is intentional.");
|
|
363
|
+
}
|
|
364
|
+
if (plan.boundaryVerdict?.humanRequired) {
|
|
365
|
+
askHuman.push(`Human gate '${plan.boundaryVerdict.humanGate}' applies to this saved intent.`);
|
|
366
|
+
}
|
|
367
|
+
return askHuman;
|
|
368
|
+
}
|
|
369
|
+
function buildRepairActions(input) {
|
|
370
|
+
const actions = [];
|
|
371
|
+
if (input.validation.driftVerdict.status === "pass") {
|
|
372
|
+
input.verificationTargets.slice(0, 8).forEach((target) => {
|
|
373
|
+
actions.push({
|
|
374
|
+
type: "verify",
|
|
375
|
+
priority: "required",
|
|
376
|
+
target,
|
|
377
|
+
reason: "Staged changes match the saved intent; verification is the remaining handoff step.",
|
|
378
|
+
instruction: `Run or inspect ${target} before handing off the change.`,
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
if (actions.length === 0) {
|
|
382
|
+
actions.push({
|
|
383
|
+
type: "proceed",
|
|
384
|
+
priority: "recommended",
|
|
385
|
+
reason: "Staged changes match the saved intent and Ripple found no verification targets.",
|
|
386
|
+
instruction: "Proceed only after doing the narrowest manual check that fits the changed file.",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return actions;
|
|
390
|
+
}
|
|
391
|
+
// Ripple readiness snapshot is saved with the intent.
|
|
392
|
+
if (input.validation.policyDrift.status === "changed") {
|
|
393
|
+
actions.push({
|
|
394
|
+
type: "review-policy",
|
|
395
|
+
priority: "blocker",
|
|
396
|
+
target: input.validation.targetFile,
|
|
397
|
+
command: `ripple policy explain --file ${input.validation.targetFile} --agent`,
|
|
398
|
+
reason: "Current repo policy differs from the policy snapshot saved with this intent.",
|
|
399
|
+
instruction: "Ask the human to review the current policy and create a new saved intent if the trust boundary should change.",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (input.validation.readinessDrift.status === "weakened") {
|
|
403
|
+
actions.push({
|
|
404
|
+
type: "review-readiness",
|
|
405
|
+
priority: "blocker",
|
|
406
|
+
target: input.validation.targetFile,
|
|
407
|
+
command: "ripple doctor --agent --strict",
|
|
408
|
+
reason: "Current Ripple readiness is weaker than the readiness snapshot saved with this intent.",
|
|
409
|
+
instruction: "Restore the missing Ripple readiness layer or ask the human to approve continuing with weaker protection.",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const contextOnlyFiles = new Set(input.validation.contextFilesChanged);
|
|
413
|
+
input.unstageFiles.forEach((file) => {
|
|
414
|
+
const isContextOnlyFile = contextOnlyFiles.has(file);
|
|
415
|
+
actions.push({
|
|
416
|
+
type: "unstage-file",
|
|
417
|
+
priority: "blocker",
|
|
418
|
+
target: file,
|
|
419
|
+
command: `git restore --staged -- ${file}`,
|
|
420
|
+
reason: isContextOnlyFile
|
|
421
|
+
? "This file was provided as read or verification context, not editable scope."
|
|
422
|
+
: "This file is outside the saved change intent.",
|
|
423
|
+
instruction: `Unstage ${file}, or create a new saved intent if editing this file is intentional.`,
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
input.validation.unplannedSymbols.forEach((symbol) => {
|
|
427
|
+
actions.push({
|
|
428
|
+
type: "review-symbol",
|
|
429
|
+
priority: "blocker",
|
|
430
|
+
target: symbol,
|
|
431
|
+
reason: "This symbol changed outside the expected symbol focus.",
|
|
432
|
+
instruction: `Undo the accidental change to ${symbol}, or replan with a saved intent that includes it.`,
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
input.validation.boundaryVerdict.changedOutsideBoundarySymbols.forEach((symbol) => {
|
|
436
|
+
actions.push({
|
|
437
|
+
type: "review-symbol",
|
|
438
|
+
priority: "blocker",
|
|
439
|
+
target: symbol,
|
|
440
|
+
reason: "This symbol changed outside the selected agent control boundary.",
|
|
441
|
+
instruction: `Undo the accidental change to ${symbol}, or ask the human to approve a wider boundary.`,
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
input.reviewContracts.forEach((symbol) => {
|
|
445
|
+
actions.push({
|
|
446
|
+
type: "review-contract",
|
|
447
|
+
priority: "blocker",
|
|
448
|
+
target: symbol,
|
|
449
|
+
reason: "A protected or unplanned contract changed.",
|
|
450
|
+
instruction: `Inspect callers for ${symbol}; preserve its contract or create a broader saved intent before continuing.`,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
actions.push({
|
|
454
|
+
type: "replan",
|
|
455
|
+
priority: input.validation.driftVerdict.status === "danger" ? "blocker" : "required",
|
|
456
|
+
command: `ripple plan --file ${input.validation.targetFile} --task "<updated task>" --mode ${input.validation.controlMode} --agent --save`,
|
|
457
|
+
reason: "The staged change no longer matches the saved plan or selected control boundary.",
|
|
458
|
+
instruction: "If the broader scope is intentional, create a new saved intent with the human-approved boundary and run the staged check again.",
|
|
459
|
+
});
|
|
460
|
+
input.verificationTargets.slice(0, 8).forEach((target) => {
|
|
461
|
+
actions.push({
|
|
462
|
+
type: "verify",
|
|
463
|
+
priority: "recommended",
|
|
464
|
+
target,
|
|
465
|
+
reason: "Use after drift is repaired or explicitly replanned.",
|
|
466
|
+
instruction: `Run or inspect ${target} after the staged scope matches intent.`,
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
return uniqueRepairActions(actions);
|
|
470
|
+
}
|
|
471
|
+
function uniqueRepairActions(actions) {
|
|
472
|
+
const seen = new Set();
|
|
473
|
+
return actions.filter((action) => {
|
|
474
|
+
const key = [
|
|
475
|
+
action.type,
|
|
476
|
+
action.priority,
|
|
477
|
+
action.target ?? "",
|
|
478
|
+
action.command ?? "",
|
|
479
|
+
action.instruction,
|
|
480
|
+
].join("\0");
|
|
481
|
+
if (seen.has(key)) {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
seen.add(key);
|
|
485
|
+
return true;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
function saveChangeIntent(workspaceRoot, intent, intentPath) {
|
|
489
|
+
const targetPath = intentPath
|
|
490
|
+
? resolveIntentPath(workspaceRoot, intentPath)
|
|
491
|
+
: defaultChangeIntentPath(workspaceRoot);
|
|
492
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
493
|
+
fs.writeFileSync(targetPath, `${JSON.stringify(intent, null, 2)}\n`, "utf8");
|
|
494
|
+
return targetPath;
|
|
495
|
+
}
|
|
496
|
+
exports.saveChangeIntent = saveChangeIntent;
|
|
497
|
+
function loadChangeIntent(workspaceRoot, intentPath = "latest") {
|
|
498
|
+
const targetPath = resolveIntentPath(workspaceRoot, intentPath);
|
|
499
|
+
const parsed = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
500
|
+
return assertChangeIntent(parsed, targetPath);
|
|
501
|
+
}
|
|
502
|
+
exports.loadChangeIntent = loadChangeIntent;
|
|
503
|
+
function defaultChangeIntentPath(workspaceRoot) {
|
|
504
|
+
return path.join(workspaceRoot, INTENTS_DIR, LATEST_INTENT_FILE);
|
|
505
|
+
}
|
|
506
|
+
exports.defaultChangeIntentPath = defaultChangeIntentPath;
|
|
507
|
+
function buildIntentValidation(staged, intent, options) {
|
|
508
|
+
const editableFiles = changeIntentEditableFiles(intent);
|
|
509
|
+
const contextFiles = changeIntentContextFiles(intent, editableFiles);
|
|
510
|
+
const editableFileSet = new Set(editableFiles);
|
|
511
|
+
const contextFileSet = new Set(contextFiles);
|
|
512
|
+
const expectedSymbols = new Set(intent.expectedSymbols);
|
|
513
|
+
const protectedContracts = new Set(intent.protectedContracts);
|
|
514
|
+
const changedFiles = staged.files.map((file) => file.file);
|
|
515
|
+
const changedSymbols = staged.changedSymbols.map((symbol) => symbol.symbol);
|
|
516
|
+
const plannedFilesChanged = changedFiles.filter((file) => editableFileSet.has(file));
|
|
517
|
+
const contextFilesChanged = changedFiles.filter((file) => contextFileSet.has(file) && !editableFileSet.has(file));
|
|
518
|
+
const unplannedFiles = changedFiles.filter((file) => !editableFileSet.has(file));
|
|
519
|
+
const expectedSymbolsChanged = changedSymbols.filter((symbol) => expectedSymbols.has(symbol));
|
|
520
|
+
const unplannedSymbols = staged.changedSymbols
|
|
521
|
+
.filter((symbol) => !expectedSymbols.has(symbol.symbol) && !editableFileSet.has(symbol.file))
|
|
522
|
+
.map((symbol) => symbol.symbol);
|
|
523
|
+
const protectedContractChanges = contractChangedSymbols(staged.changedSymbols)
|
|
524
|
+
.filter((symbol) => protectedContracts.has(symbol.symbol))
|
|
525
|
+
.map((symbol) => symbol.symbol);
|
|
526
|
+
const unplannedContractChanges = contractChangedSymbols(staged.changedSymbols)
|
|
527
|
+
.filter((symbol) => !protectedContracts.has(symbol.symbol))
|
|
528
|
+
.map((symbol) => symbol.symbol);
|
|
529
|
+
const reasons = validationReasons({
|
|
530
|
+
unplannedFiles,
|
|
531
|
+
contextFilesChanged,
|
|
532
|
+
unplannedSymbols,
|
|
533
|
+
protectedContractChanges,
|
|
534
|
+
unplannedContractChanges,
|
|
535
|
+
});
|
|
536
|
+
const verdict = validationVerdict({
|
|
537
|
+
unplannedFiles,
|
|
538
|
+
unplannedSymbols,
|
|
539
|
+
protectedContractChanges,
|
|
540
|
+
unplannedContractChanges,
|
|
541
|
+
});
|
|
542
|
+
const guidance = validationGuidance({
|
|
543
|
+
verdict,
|
|
544
|
+
unplannedFiles,
|
|
545
|
+
contextFilesChanged,
|
|
546
|
+
unplannedSymbols,
|
|
547
|
+
protectedContractChanges,
|
|
548
|
+
unplannedContractChanges,
|
|
549
|
+
});
|
|
550
|
+
const verificationTargets = uniqueItems(staged.files.flatMap((file) => file.verificationTargets));
|
|
551
|
+
const boundaryVerdict = buildBoundaryVerdict({
|
|
552
|
+
intent,
|
|
553
|
+
editableFiles,
|
|
554
|
+
changedFiles,
|
|
555
|
+
changedSymbols: staged.changedSymbols,
|
|
556
|
+
});
|
|
557
|
+
const policyDrift = buildPolicyDriftSummary(intent.policyExplanation, options.currentPolicyExplanation);
|
|
558
|
+
const readinessDrift = buildReadinessDriftSummary(intent.readinessSnapshot, options.currentReadinessSnapshot);
|
|
559
|
+
const boundaryGuidance = mergeBoundaryGuidance(guidance, boundaryVerdict);
|
|
560
|
+
const policyGuidance = mergePolicyDriftGuidance(boundaryGuidance, policyDrift);
|
|
561
|
+
const effectiveGuidance = mergeReadinessDriftGuidance(policyGuidance, readinessDrift);
|
|
562
|
+
const driftVerdict = buildDriftVerdict({
|
|
563
|
+
verdict,
|
|
564
|
+
boundaryVerdict,
|
|
565
|
+
policyDrift,
|
|
566
|
+
readinessDrift,
|
|
567
|
+
reasons,
|
|
568
|
+
blockingReasons: effectiveGuidance.blockingReasons,
|
|
569
|
+
nextSteps: effectiveGuidance.nextSteps,
|
|
570
|
+
verificationTargets,
|
|
571
|
+
contextFilesChanged,
|
|
572
|
+
unplannedFiles,
|
|
573
|
+
unplannedSymbols,
|
|
574
|
+
protectedContractChanges,
|
|
575
|
+
unplannedContractChanges,
|
|
576
|
+
});
|
|
577
|
+
const nextRequiredPhase = validationNextRequiredPhase({
|
|
578
|
+
driftVerdict,
|
|
579
|
+
boundaryVerdict,
|
|
580
|
+
policyDrift,
|
|
581
|
+
readinessDrift,
|
|
582
|
+
});
|
|
583
|
+
const nextRequiredAction = validationNextRequiredAction(nextRequiredPhase);
|
|
584
|
+
const validation = {
|
|
585
|
+
intentId: intent.id,
|
|
586
|
+
targetFile: intent.targetFile,
|
|
587
|
+
task: intent.task,
|
|
588
|
+
verdict,
|
|
589
|
+
driftVerdict,
|
|
590
|
+
boundaryVerdict,
|
|
591
|
+
controlMode: intent.controlMode,
|
|
592
|
+
allowedFiles: editableFiles,
|
|
593
|
+
allowedSymbols: intent.allowedSymbols,
|
|
594
|
+
humanGate: intent.humanGate,
|
|
595
|
+
humanGateReason: intent.humanGateReason,
|
|
596
|
+
boundaryRisk: intent.boundaryRisk,
|
|
597
|
+
policyExplanation: intent.policyExplanation,
|
|
598
|
+
policyDrift,
|
|
599
|
+
readinessDrift,
|
|
600
|
+
plannedScope: unplannedFiles.length === 0 ? "matched" : "violated",
|
|
601
|
+
editableFiles,
|
|
602
|
+
contextFiles,
|
|
603
|
+
plannedFilesChanged,
|
|
604
|
+
contextFilesChanged,
|
|
605
|
+
expectedSymbolsChanged,
|
|
606
|
+
unplannedFiles,
|
|
607
|
+
unplannedSymbols,
|
|
608
|
+
protectedContractChanges,
|
|
609
|
+
unplannedContractChanges,
|
|
610
|
+
reasons,
|
|
611
|
+
recommendedAction: effectiveGuidance.recommendedAction,
|
|
612
|
+
nextRequiredPhase,
|
|
613
|
+
nextRequiredAction,
|
|
614
|
+
blockingReasons: effectiveGuidance.blockingReasons,
|
|
615
|
+
nextSteps: effectiveGuidance.nextSteps,
|
|
616
|
+
requiresAttention: driftVerdict.status !== "pass" ||
|
|
617
|
+
boundaryVerdict.humanRequired ||
|
|
618
|
+
policyDrift.status === "changed" ||
|
|
619
|
+
readinessDrift.status === "weakened",
|
|
620
|
+
};
|
|
621
|
+
return {
|
|
622
|
+
...validation,
|
|
623
|
+
handoff: buildValidationHandoff(validation),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function validationNextRequiredPhase(input) {
|
|
627
|
+
if (input.policyDrift.status === "changed" ||
|
|
628
|
+
input.readinessDrift?.status === "weakened" ||
|
|
629
|
+
input.boundaryVerdict.status !== "pass" ||
|
|
630
|
+
input.driftVerdict.status !== "pass") {
|
|
631
|
+
return "repair_or_handoff";
|
|
632
|
+
}
|
|
633
|
+
return "audit_after_change";
|
|
634
|
+
}
|
|
635
|
+
function validationNextRequiredAction(phase) {
|
|
636
|
+
if (phase === "repair_or_handoff") {
|
|
637
|
+
return "Run ripple repair --agent --intent latest, then repair or ask the human before continuing.";
|
|
638
|
+
}
|
|
639
|
+
if (phase === "audit_after_change") {
|
|
640
|
+
return "Run ripple audit --agent --intent latest before final handoff; audit checks approval status and final proceed/stop decision.";
|
|
641
|
+
}
|
|
642
|
+
return "No staged-check follow-up is required.";
|
|
643
|
+
}
|
|
644
|
+
function buildValidationHandoff(validation) {
|
|
645
|
+
const needsHuman = validation.boundaryVerdict.humanRequired ||
|
|
646
|
+
validation.driftVerdict.decision === "stop-and-ask-human" ||
|
|
647
|
+
validation.policyDrift.status === "changed" ||
|
|
648
|
+
validation.readinessDrift.status === "weakened" ||
|
|
649
|
+
validation.verdict === "dangerous";
|
|
650
|
+
const canContinue = validation.driftVerdict.status === "pass" &&
|
|
651
|
+
!validation.requiresAttention;
|
|
652
|
+
const decision = validationHandoffDecision(validation, canContinue, needsHuman);
|
|
653
|
+
return buildAgentHandoffVerdict({
|
|
654
|
+
source: "check",
|
|
655
|
+
canContinue,
|
|
656
|
+
needsHuman,
|
|
657
|
+
decision,
|
|
658
|
+
nextRequiredPhase: validation.nextRequiredPhase,
|
|
659
|
+
nextRequiredAction: validation.nextRequiredAction,
|
|
660
|
+
summary: validation.recommendedAction,
|
|
661
|
+
why: validation.blockingReasons.length > 0
|
|
662
|
+
? validation.blockingReasons
|
|
663
|
+
: validation.driftVerdict.why,
|
|
664
|
+
fixNow: validationHandoffFixNow(validation),
|
|
665
|
+
askHuman: validationHandoffAskHuman(validation),
|
|
666
|
+
commands: validationHandoffCommands(validation),
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
function validationHandoffDecision(validation, canContinue, needsHuman) {
|
|
670
|
+
if (validation.readinessDrift.status === "weakened") {
|
|
671
|
+
return "restore-readiness";
|
|
672
|
+
}
|
|
673
|
+
if (needsHuman) {
|
|
674
|
+
return "human-review";
|
|
675
|
+
}
|
|
676
|
+
if (!canContinue) {
|
|
677
|
+
return "repair";
|
|
678
|
+
}
|
|
679
|
+
if (validation.nextRequiredPhase === "audit_after_change") {
|
|
680
|
+
return "audit";
|
|
681
|
+
}
|
|
682
|
+
return "continue";
|
|
683
|
+
}
|
|
684
|
+
function validationHandoffFixNow(validation) {
|
|
685
|
+
if (validation.driftVerdict.status === "pass" && !validation.requiresAttention) {
|
|
686
|
+
return validation.nextSteps;
|
|
687
|
+
}
|
|
688
|
+
return uniqueItems([
|
|
689
|
+
...validation.driftVerdict.fix,
|
|
690
|
+
...validation.boundaryVerdict.fix,
|
|
691
|
+
...(validation.policyDrift.status === "changed" ? validation.policyDrift.fix : []),
|
|
692
|
+
...(validation.readinessDrift.status === "weakened" ? validation.readinessDrift.fix : []),
|
|
693
|
+
]);
|
|
694
|
+
}
|
|
695
|
+
function validationHandoffAskHuman(validation) {
|
|
696
|
+
const askHuman = [];
|
|
697
|
+
if (validation.boundaryVerdict.humanRequired) {
|
|
698
|
+
askHuman.push(`Human gate '${validation.humanGate}' applies to ${validation.targetFile}.`);
|
|
699
|
+
}
|
|
700
|
+
if (validation.policyDrift.status === "changed") {
|
|
701
|
+
askHuman.push("Review the saved plan against the current repo policy before continuing.");
|
|
702
|
+
}
|
|
703
|
+
if (validation.readinessDrift.status === "weakened") {
|
|
704
|
+
askHuman.push("Approve continuing only if the weaker Ripple readiness is intentional.");
|
|
705
|
+
}
|
|
706
|
+
if (validation.verdict === "dangerous") {
|
|
707
|
+
askHuman.push("Review contract drift before keeping the staged change.");
|
|
708
|
+
}
|
|
709
|
+
if (validation.driftVerdict.decision === "stop-and-ask-human") {
|
|
710
|
+
askHuman.push(validation.driftVerdict.summary);
|
|
711
|
+
}
|
|
712
|
+
return askHuman;
|
|
713
|
+
}
|
|
714
|
+
function validationHandoffCommands(validation) {
|
|
715
|
+
return {
|
|
716
|
+
doctor: validation.readinessDrift.status === "weakened"
|
|
717
|
+
? ["ripple doctor --agent --strict"]
|
|
718
|
+
: [],
|
|
719
|
+
plan: validation.verdict === "matched"
|
|
720
|
+
? []
|
|
721
|
+
: [`ripple plan --file ${validation.targetFile} --task "<updated task>" --mode ${validation.controlMode} --agent --save`],
|
|
722
|
+
audit: validation.nextRequiredPhase === "audit_after_change"
|
|
723
|
+
? ["ripple audit --agent --intent latest"]
|
|
724
|
+
: [],
|
|
725
|
+
repair: validation.nextRequiredPhase === "repair_or_handoff"
|
|
726
|
+
? ["ripple repair --agent --intent latest"]
|
|
727
|
+
: [],
|
|
728
|
+
approve: validation.boundaryVerdict.humanRequired
|
|
729
|
+
? [
|
|
730
|
+
"ripple approval --intent latest --agent",
|
|
731
|
+
`ripple approve --intent latest --gate ${approvalGateForHumanGate(validation.humanGate)}`,
|
|
732
|
+
]
|
|
733
|
+
: [],
|
|
734
|
+
unstage: uniqueItems([
|
|
735
|
+
...validation.unplannedFiles,
|
|
736
|
+
...validation.contextFilesChanged,
|
|
737
|
+
...validation.boundaryVerdict.changedOutsideBoundaryFiles,
|
|
738
|
+
]).map((file) => `git restore --staged -- ${file}`),
|
|
739
|
+
verify: validation.driftVerdict.status === "pass"
|
|
740
|
+
? validation.driftVerdict.fix
|
|
741
|
+
: validation.nextSteps,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function approvalGateForHumanGate(humanGate) {
|
|
745
|
+
if (humanGate === "required-before-merge") {
|
|
746
|
+
return "before-merge";
|
|
747
|
+
}
|
|
748
|
+
return "before-risky-edit";
|
|
749
|
+
}
|
|
750
|
+
function mergeBoundaryGuidance(guidance, boundaryVerdict) {
|
|
751
|
+
if (boundaryVerdict.status === "pass") {
|
|
752
|
+
return guidance;
|
|
753
|
+
}
|
|
754
|
+
const hasIntentBlockingReason = guidance.blockingReasons.length > 0;
|
|
755
|
+
return {
|
|
756
|
+
recommendedAction: boundaryVerdict.status === "danger"
|
|
757
|
+
? "Stop and ask the human to approve the crossed control boundary before keeping these changes."
|
|
758
|
+
: hasIntentBlockingReason
|
|
759
|
+
? guidance.recommendedAction
|
|
760
|
+
: "Repair boundary drift by undoing changes outside the selected control boundary or save a wider human-approved intent.",
|
|
761
|
+
blockingReasons: uniqueItems([
|
|
762
|
+
...guidance.blockingReasons,
|
|
763
|
+
...boundaryVerdict.why,
|
|
764
|
+
]),
|
|
765
|
+
nextSteps: uniqueItems([
|
|
766
|
+
...boundaryVerdict.fix,
|
|
767
|
+
...guidance.nextSteps,
|
|
768
|
+
]),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function mergePolicyDriftGuidance(guidance, policyDrift) {
|
|
772
|
+
if (policyDrift.status !== "changed") {
|
|
773
|
+
return guidance;
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
recommendedAction: guidance.blockingReasons.length > 0
|
|
777
|
+
? guidance.recommendedAction
|
|
778
|
+
: "Ask the human to review the saved intent against the current repo policy before continuing.",
|
|
779
|
+
blockingReasons: uniqueItems([
|
|
780
|
+
...guidance.blockingReasons,
|
|
781
|
+
...policyDrift.why,
|
|
782
|
+
]),
|
|
783
|
+
nextSteps: uniqueItems([
|
|
784
|
+
...policyDrift.fix,
|
|
785
|
+
...guidance.nextSteps,
|
|
786
|
+
]),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
function mergeReadinessDriftGuidance(guidance, readinessDrift) {
|
|
790
|
+
if (readinessDrift.status !== "weakened") {
|
|
791
|
+
return guidance;
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
recommendedAction: guidance.blockingReasons.length > 0
|
|
795
|
+
? guidance.recommendedAction
|
|
796
|
+
: "Restore Ripple readiness or ask the human to approve continuing with weaker protection.",
|
|
797
|
+
blockingReasons: uniqueItems([
|
|
798
|
+
...guidance.blockingReasons,
|
|
799
|
+
...readinessDrift.why,
|
|
800
|
+
]),
|
|
801
|
+
nextSteps: uniqueItems([
|
|
802
|
+
...readinessDrift.fix,
|
|
803
|
+
...guidance.nextSteps,
|
|
804
|
+
]),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
function buildPolicyDriftSummary(saved, current) {
|
|
808
|
+
if (!current) {
|
|
809
|
+
return {
|
|
810
|
+
status: "unchecked",
|
|
811
|
+
decision: "compare-current-policy",
|
|
812
|
+
label: "UNKNOWN",
|
|
813
|
+
summary: "UNKNOWN: current repo policy was not compared with the saved intent policy snapshot.",
|
|
814
|
+
changedFields: [],
|
|
815
|
+
why: ["No current policy explanation was provided during intent validation."],
|
|
816
|
+
fix: ["Compare the saved intent policyExplanation with the current repo policy before final handoff."],
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const changedFields = policyExplanationChangedFields(saved, current);
|
|
820
|
+
if (changedFields.length === 0) {
|
|
821
|
+
return {
|
|
822
|
+
status: "unchanged",
|
|
823
|
+
decision: "continue",
|
|
824
|
+
label: "PASS",
|
|
825
|
+
summary: "PASS: current repo policy matches the saved intent policy snapshot.",
|
|
826
|
+
changedFields: [],
|
|
827
|
+
why: ["The effective repo policy for this target still matches the saved intent."],
|
|
828
|
+
fix: ["Continue with normal staged-change and boundary validation."],
|
|
829
|
+
currentPolicyExplanation: current,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
status: "changed",
|
|
834
|
+
decision: "review-current-policy",
|
|
835
|
+
label: "DRIFT",
|
|
836
|
+
summary: "DRIFT: current repo policy differs from the policy snapshot saved with this intent.",
|
|
837
|
+
changedFields,
|
|
838
|
+
why: [
|
|
839
|
+
"The saved intent was created under a different effective repo policy.",
|
|
840
|
+
...changedFields.map((field) => `Policy changed: ${field}`),
|
|
841
|
+
],
|
|
842
|
+
fix: [
|
|
843
|
+
"Ask the human to review the saved intent against the current repo policy.",
|
|
844
|
+
"Create a new saved intent if the current policy requires a different trust boundary.",
|
|
845
|
+
],
|
|
846
|
+
currentPolicyExplanation: current,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function buildReadinessDriftSummary(saved, current) {
|
|
850
|
+
if (!current) {
|
|
851
|
+
return {
|
|
852
|
+
status: "unchecked",
|
|
853
|
+
decision: "compare-current-readiness",
|
|
854
|
+
label: "UNKNOWN",
|
|
855
|
+
summary: "UNKNOWN: current Ripple readiness was not compared with the saved intent readiness snapshot.",
|
|
856
|
+
changedFields: [],
|
|
857
|
+
weakenedFields: [],
|
|
858
|
+
savedReadiness: saved,
|
|
859
|
+
why: ["No current readiness snapshot was provided during intent validation."],
|
|
860
|
+
fix: ["Run ripple doctor --agent before final handoff."],
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const changedFields = readinessChangedFields(saved, current);
|
|
864
|
+
const weakenedFields = readinessWeakenedFields(saved, current);
|
|
865
|
+
if (weakenedFields.length === 0) {
|
|
866
|
+
return {
|
|
867
|
+
status: "unchanged",
|
|
868
|
+
decision: "continue",
|
|
869
|
+
label: "PASS",
|
|
870
|
+
summary: "PASS: current Ripple readiness is the same as or stronger than the saved intent readiness snapshot.",
|
|
871
|
+
changedFields,
|
|
872
|
+
weakenedFields,
|
|
873
|
+
savedReadiness: saved,
|
|
874
|
+
currentReadiness: current,
|
|
875
|
+
why: [
|
|
876
|
+
`Saved enforcement level: ${saved.enforcementLevel}.`,
|
|
877
|
+
`Current enforcement level: ${current.enforcementLevel}.`,
|
|
878
|
+
],
|
|
879
|
+
fix: ["Continue with normal staged-change and boundary validation."],
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
status: "weakened",
|
|
884
|
+
decision: "restore-readiness",
|
|
885
|
+
label: "DRIFT",
|
|
886
|
+
summary: "DRIFT: current Ripple readiness is weaker than the readiness snapshot saved with this intent.",
|
|
887
|
+
changedFields,
|
|
888
|
+
weakenedFields,
|
|
889
|
+
savedReadiness: saved,
|
|
890
|
+
currentReadiness: current,
|
|
891
|
+
why: readinessDriftWhy(saved, current, weakenedFields),
|
|
892
|
+
fix: readinessDriftFix(current, weakenedFields),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
function readinessChangedFields(saved, current) {
|
|
896
|
+
const fields = [
|
|
897
|
+
"status",
|
|
898
|
+
"enforcementLevel",
|
|
899
|
+
"canGuideAgents",
|
|
900
|
+
"canDetectDrift",
|
|
901
|
+
"canBlockInCi",
|
|
902
|
+
"policyExplicit",
|
|
903
|
+
"graphOk",
|
|
904
|
+
"gitOk",
|
|
905
|
+
"ciWorkflowOk",
|
|
906
|
+
"latestIntentOk",
|
|
907
|
+
];
|
|
908
|
+
return fields.filter((field) => saved[field] !== current[field]);
|
|
909
|
+
}
|
|
910
|
+
function readinessWeakenedFields(saved, current) {
|
|
911
|
+
const weakened = [];
|
|
912
|
+
if (readinessStatusRank(current.status) < readinessStatusRank(saved.status)) {
|
|
913
|
+
weakened.push("status");
|
|
914
|
+
}
|
|
915
|
+
if (enforcementLevelRank(current.enforcementLevel) < enforcementLevelRank(saved.enforcementLevel)) {
|
|
916
|
+
weakened.push("enforcementLevel");
|
|
917
|
+
}
|
|
918
|
+
const booleanFields = [
|
|
919
|
+
"canGuideAgents",
|
|
920
|
+
"canDetectDrift",
|
|
921
|
+
"canBlockInCi",
|
|
922
|
+
"policyExplicit",
|
|
923
|
+
"graphOk",
|
|
924
|
+
"gitOk",
|
|
925
|
+
"ciWorkflowOk",
|
|
926
|
+
"latestIntentOk",
|
|
927
|
+
];
|
|
928
|
+
booleanFields.forEach((field) => {
|
|
929
|
+
if (saved[field] && !current[field]) {
|
|
930
|
+
weakened.push(field);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
return uniqueItems(weakened);
|
|
934
|
+
}
|
|
935
|
+
function readinessDriftWhy(saved, current, weakenedFields) {
|
|
936
|
+
return [
|
|
937
|
+
`Saved enforcement level: ${saved.enforcementLevel}.`,
|
|
938
|
+
`Current enforcement level: ${current.enforcementLevel}.`,
|
|
939
|
+
`Weakened readiness fields: ${weakenedFields.join(", ")}.`,
|
|
940
|
+
...current.gaps.map((gap) => `Current readiness gap: ${gap}`),
|
|
941
|
+
];
|
|
942
|
+
}
|
|
943
|
+
function readinessDriftFix(current, weakenedFields) {
|
|
944
|
+
const fixes = ["Run ripple doctor --agent --strict to inspect current readiness gaps."];
|
|
945
|
+
if (weakenedFields.includes("ciWorkflowOk") || weakenedFields.includes("canBlockInCi")) {
|
|
946
|
+
fixes.push("Run ripple init to restore setup files and CI gate readiness.");
|
|
947
|
+
}
|
|
948
|
+
if (weakenedFields.includes("policyExplicit")) {
|
|
949
|
+
fixes.push("Restore .ripple/policy.json or run ripple init before continuing.");
|
|
950
|
+
}
|
|
951
|
+
if (weakenedFields.includes("latestIntentOk") || weakenedFields.includes("canDetectDrift")) {
|
|
952
|
+
fixes.push("Create or restore the saved intent with ripple plan --agent --save.");
|
|
953
|
+
}
|
|
954
|
+
if (weakenedFields.includes("gitOk")) {
|
|
955
|
+
fixes.push("Run Ripple inside a git worktree so changed-file drift checks can work.");
|
|
956
|
+
}
|
|
957
|
+
if (weakenedFields.includes("graphOk") || weakenedFields.includes("canGuideAgents")) {
|
|
958
|
+
fixes.push("Run Ripple from a supported source repo so the graph can be scanned.");
|
|
959
|
+
}
|
|
960
|
+
current.nextSteps.forEach((step) => fixes.push(step));
|
|
961
|
+
return uniqueItems(fixes);
|
|
962
|
+
}
|
|
963
|
+
function readinessStatusRank(status) {
|
|
964
|
+
return status === "ready" ? 1 : 0;
|
|
965
|
+
}
|
|
966
|
+
function enforcementLevelRank(level) {
|
|
967
|
+
if (level === "ci-gate-ready") {
|
|
968
|
+
return 3;
|
|
969
|
+
}
|
|
970
|
+
if (level === "drift-check-ready") {
|
|
971
|
+
return 2;
|
|
972
|
+
}
|
|
973
|
+
if (level === "advisory") {
|
|
974
|
+
return 1;
|
|
975
|
+
}
|
|
976
|
+
return 0;
|
|
977
|
+
}
|
|
978
|
+
function policyExplanationChangedFields(saved, current) {
|
|
979
|
+
const changed = [];
|
|
980
|
+
pushPolicyFieldChange(changed, "policy_source", saved.policySource, current.policySource);
|
|
981
|
+
pushPolicyFieldChange(changed, "policy_exists", saved.policyExists, current.policyExists);
|
|
982
|
+
pushPolicyFieldChange(changed, "policy_risk", saved.policyRisk, current.policyRisk);
|
|
983
|
+
pushPolicyFieldChange(changed, "human_gate", saved.humanGate, current.humanGate);
|
|
984
|
+
pushPolicyFieldChange(changed, "human_required", saved.humanRequired, current.humanRequired);
|
|
985
|
+
pushPolicyFieldChange(changed, "allow_pr_mode", saved.allowPrMode, current.allowPrMode);
|
|
986
|
+
if (!sameStringList(saved.matchedRules, current.matchedRules)) {
|
|
987
|
+
changed.push(`matched_rules saved=${formatPolicyValue(saved.matchedRules)} current=${formatPolicyValue(current.matchedRules)}`);
|
|
988
|
+
}
|
|
989
|
+
return changed;
|
|
990
|
+
}
|
|
991
|
+
function pushPolicyFieldChange(changed, field, saved, current) {
|
|
992
|
+
if (saved !== current) {
|
|
993
|
+
changed.push(`${field} saved=${formatPolicyValue(saved)} current=${formatPolicyValue(current)}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function sameStringList(left, right) {
|
|
997
|
+
if (left.length !== right.length) {
|
|
998
|
+
return false;
|
|
999
|
+
}
|
|
1000
|
+
return left.every((item, index) => item === right[index]);
|
|
1001
|
+
}
|
|
1002
|
+
function formatPolicyValue(value) {
|
|
1003
|
+
if (Array.isArray(value)) {
|
|
1004
|
+
return value.length > 0 ? `[${value.join("; ")}]` : "[]";
|
|
1005
|
+
}
|
|
1006
|
+
return String(value);
|
|
1007
|
+
}
|
|
1008
|
+
function buildBoundaryVerdict(input) {
|
|
1009
|
+
const allowedFileSet = new Set(input.editableFiles);
|
|
1010
|
+
const allowedSymbolSet = new Set(input.intent.allowedSymbols);
|
|
1011
|
+
const humanRequired = input.intent.humanGate !== "none";
|
|
1012
|
+
const changedOutsideBoundaryFiles = uniqueItems(input.changedFiles.filter((file) => !allowedFileSet.has(file)));
|
|
1013
|
+
const changedOutsideBoundarySymbols = uniqueItems(input.changedSymbols
|
|
1014
|
+
.filter((symbol) => {
|
|
1015
|
+
if (input.intent.controlMode === "brainstorm") {
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
if (input.intent.controlMode !== "function") {
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
return allowedFileSet.has(symbol.file) && !allowedSymbolSet.has(symbol.symbol);
|
|
1022
|
+
})
|
|
1023
|
+
.map((symbol) => symbol.symbol));
|
|
1024
|
+
const crossedBoundary = changedOutsideBoundaryFiles.length > 0 ||
|
|
1025
|
+
changedOutsideBoundarySymbols.length > 0;
|
|
1026
|
+
if (!crossedBoundary) {
|
|
1027
|
+
return {
|
|
1028
|
+
status: "pass",
|
|
1029
|
+
decision: "continue",
|
|
1030
|
+
label: "PASS",
|
|
1031
|
+
controlMode: input.intent.controlMode,
|
|
1032
|
+
humanRequired,
|
|
1033
|
+
humanGate: input.intent.humanGate,
|
|
1034
|
+
summary: humanRequired
|
|
1035
|
+
? "PASS: staged changes stayed inside the selected boundary; the saved human gate still applies."
|
|
1036
|
+
: "PASS: staged changes stayed inside the selected boundary.",
|
|
1037
|
+
why: boundaryPassReasons(input.intent, input.editableFiles),
|
|
1038
|
+
fix: humanRequired
|
|
1039
|
+
? [`Respect human gate: ${input.intent.humanGate}.`]
|
|
1040
|
+
: ["Continue only if the staged change still matches the task intent."],
|
|
1041
|
+
changedOutsideBoundaryFiles: [],
|
|
1042
|
+
changedOutsideBoundarySymbols: [],
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
const status = humanRequired ? "danger" : "drift";
|
|
1046
|
+
const decision = status === "danger" ? "stop-and-ask-human" : "fix-before-commit";
|
|
1047
|
+
const why = uniqueItems([
|
|
1048
|
+
...boundaryPassReasons(input.intent, input.editableFiles),
|
|
1049
|
+
...changedOutsideBoundaryFiles.map((file) => {
|
|
1050
|
+
return `Changed file outside ${input.intent.controlMode} boundary: ${file}`;
|
|
1051
|
+
}),
|
|
1052
|
+
...changedOutsideBoundarySymbols.map((symbol) => {
|
|
1053
|
+
return `Changed symbol outside ${input.intent.controlMode} boundary: ${symbol}`;
|
|
1054
|
+
}),
|
|
1055
|
+
...input.intent.humanGateReason,
|
|
1056
|
+
]);
|
|
1057
|
+
const fix = uniqueItems([
|
|
1058
|
+
...changedOutsideBoundaryFiles.map((file) => {
|
|
1059
|
+
return `Unstage file outside boundary: ${file}`;
|
|
1060
|
+
}),
|
|
1061
|
+
...changedOutsideBoundarySymbols.map((symbol) => {
|
|
1062
|
+
return `Undo or replan unapproved symbol: ${symbol}`;
|
|
1063
|
+
}),
|
|
1064
|
+
"Ask the human to approve a wider boundary before keeping these changes.",
|
|
1065
|
+
]);
|
|
1066
|
+
return {
|
|
1067
|
+
status,
|
|
1068
|
+
decision,
|
|
1069
|
+
label: status === "danger" ? "DANGER" : "DRIFT",
|
|
1070
|
+
controlMode: input.intent.controlMode,
|
|
1071
|
+
humanRequired,
|
|
1072
|
+
humanGate: input.intent.humanGate,
|
|
1073
|
+
summary: status === "danger"
|
|
1074
|
+
? "DANGER: staged changes crossed a human-gated control boundary."
|
|
1075
|
+
: "DRIFT: staged changes crossed the selected control boundary.",
|
|
1076
|
+
why,
|
|
1077
|
+
fix,
|
|
1078
|
+
changedOutsideBoundaryFiles,
|
|
1079
|
+
changedOutsideBoundarySymbols,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function boundaryPassReasons(intent, editableFiles) {
|
|
1083
|
+
const allowedFiles = editableFiles.length > 0
|
|
1084
|
+
? editableFiles.join(", ")
|
|
1085
|
+
: "no files";
|
|
1086
|
+
const reasons = [
|
|
1087
|
+
`Control mode '${intent.controlMode}' allows edits to ${allowedFiles}.`,
|
|
1088
|
+
];
|
|
1089
|
+
if (intent.controlMode === "function") {
|
|
1090
|
+
reasons.push(intent.allowedSymbols.length > 0
|
|
1091
|
+
? `Allowed symbols: ${intent.allowedSymbols.join(", ")}.`
|
|
1092
|
+
: "No allowed symbols were saved for function mode.");
|
|
1093
|
+
}
|
|
1094
|
+
return reasons;
|
|
1095
|
+
}
|
|
1096
|
+
function buildDriftVerdict(input) {
|
|
1097
|
+
const boundaryVerdict = input.boundaryVerdict;
|
|
1098
|
+
const policyDrift = input.policyDrift;
|
|
1099
|
+
const readinessDrift = input.readinessDrift;
|
|
1100
|
+
if (input.verdict === "matched" &&
|
|
1101
|
+
boundaryVerdict.status === "pass" &&
|
|
1102
|
+
policyDrift.status !== "changed" &&
|
|
1103
|
+
readinessDrift.status !== "weakened") {
|
|
1104
|
+
const fix = input.verificationTargets.length > 0
|
|
1105
|
+
? input.verificationTargets
|
|
1106
|
+
.slice(0, 8)
|
|
1107
|
+
.map((target) => `Verify before commit: ${target}`)
|
|
1108
|
+
: ["Proceed after the narrowest manual check for the staged change."];
|
|
1109
|
+
return {
|
|
1110
|
+
status: "pass",
|
|
1111
|
+
decision: "continue",
|
|
1112
|
+
label: "PASS",
|
|
1113
|
+
summary: "PASS: staged changes stayed inside the saved Ripple plan.",
|
|
1114
|
+
why: input.reasons,
|
|
1115
|
+
fix,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
if (input.verdict === "matched" &&
|
|
1119
|
+
boundaryVerdict.status === "pass" &&
|
|
1120
|
+
policyDrift.status === "changed") {
|
|
1121
|
+
return {
|
|
1122
|
+
status: "drift",
|
|
1123
|
+
decision: "stop-and-ask-human",
|
|
1124
|
+
label: "DRIFT",
|
|
1125
|
+
summary: policyDrift.summary,
|
|
1126
|
+
why: policyDrift.why,
|
|
1127
|
+
fix: policyDrift.fix,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (input.verdict === "matched" &&
|
|
1131
|
+
boundaryVerdict.status === "pass" &&
|
|
1132
|
+
readinessDrift.status === "weakened") {
|
|
1133
|
+
return {
|
|
1134
|
+
status: "drift",
|
|
1135
|
+
decision: "stop-and-ask-human",
|
|
1136
|
+
label: "DRIFT",
|
|
1137
|
+
summary: readinessDrift.summary,
|
|
1138
|
+
why: readinessDrift.why,
|
|
1139
|
+
fix: readinessDrift.fix,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
if (input.verdict === "matched") {
|
|
1143
|
+
return {
|
|
1144
|
+
status: boundaryVerdict.status,
|
|
1145
|
+
decision: boundaryVerdict.decision,
|
|
1146
|
+
label: boundaryVerdict.label,
|
|
1147
|
+
summary: boundaryVerdict.summary,
|
|
1148
|
+
why: boundaryVerdict.why,
|
|
1149
|
+
fix: boundaryVerdict.fix,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const contextFilesChanged = new Set(input.contextFilesChanged);
|
|
1153
|
+
const outsideIntentFiles = input.unplannedFiles.filter((file) => {
|
|
1154
|
+
return !contextFilesChanged.has(file);
|
|
1155
|
+
});
|
|
1156
|
+
const intentWhy = input.blockingReasons.length > 0
|
|
1157
|
+
? input.blockingReasons
|
|
1158
|
+
: input.reasons;
|
|
1159
|
+
const policyWhy = policyDrift.status === "changed" ? policyDrift.why : [];
|
|
1160
|
+
const readinessWhy = readinessDrift.status === "weakened" ? readinessDrift.why : [];
|
|
1161
|
+
const why = uniqueItems([...intentWhy, ...boundaryVerdict.why, ...policyWhy, ...readinessWhy]);
|
|
1162
|
+
const fileFixes = [
|
|
1163
|
+
...input.contextFilesChanged.map((file) => {
|
|
1164
|
+
return `Unstage context-only file: ${file}`;
|
|
1165
|
+
}),
|
|
1166
|
+
...outsideIntentFiles.map((file) => {
|
|
1167
|
+
return `Unstage unplanned file: ${file}`;
|
|
1168
|
+
}),
|
|
1169
|
+
];
|
|
1170
|
+
const symbolFixes = input.unplannedSymbols.map((symbol) => {
|
|
1171
|
+
return `Review or undo unplanned symbol change: ${symbol}`;
|
|
1172
|
+
});
|
|
1173
|
+
const boundaryFixes = boundaryVerdict.status === "pass" ? [] : boundaryVerdict.fix;
|
|
1174
|
+
const policyFixes = policyDrift.status === "changed" ? policyDrift.fix : [];
|
|
1175
|
+
const readinessFixes = readinessDrift.status === "weakened" ? readinessDrift.fix : [];
|
|
1176
|
+
if (input.verdict === "dangerous" || boundaryVerdict.status === "danger") {
|
|
1177
|
+
const contractFixes = [
|
|
1178
|
+
...input.protectedContractChanges.map((symbol) => {
|
|
1179
|
+
return `Stop and review protected contract change: ${symbol}`;
|
|
1180
|
+
}),
|
|
1181
|
+
...input.unplannedContractChanges.map((symbol) => {
|
|
1182
|
+
return `Stop and review unplanned contract change: ${symbol}`;
|
|
1183
|
+
}),
|
|
1184
|
+
];
|
|
1185
|
+
return {
|
|
1186
|
+
status: "danger",
|
|
1187
|
+
decision: "stop-and-ask-human",
|
|
1188
|
+
label: "DANGER",
|
|
1189
|
+
summary: boundaryVerdict.status === "danger" && input.verdict !== "dangerous"
|
|
1190
|
+
? "DANGER: staged changes crossed the human-selected control boundary."
|
|
1191
|
+
: "DANGER: staged changes include contract drift or unsafe scope expansion.",
|
|
1192
|
+
why,
|
|
1193
|
+
fix: uniqueItems([
|
|
1194
|
+
...fileFixes,
|
|
1195
|
+
...symbolFixes,
|
|
1196
|
+
...boundaryFixes,
|
|
1197
|
+
...policyFixes,
|
|
1198
|
+
...readinessFixes,
|
|
1199
|
+
...contractFixes,
|
|
1200
|
+
"Ask the human before keeping any public contract change.",
|
|
1201
|
+
"Create a new saved intent only after the broader contract change is approved.",
|
|
1202
|
+
]),
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
status: "drift",
|
|
1207
|
+
decision: "fix-before-commit",
|
|
1208
|
+
label: "DRIFT",
|
|
1209
|
+
summary: "DRIFT: staged changes left the saved Ripple plan.",
|
|
1210
|
+
why,
|
|
1211
|
+
fix: uniqueItems([
|
|
1212
|
+
...fileFixes,
|
|
1213
|
+
...symbolFixes,
|
|
1214
|
+
...boundaryFixes,
|
|
1215
|
+
...policyFixes,
|
|
1216
|
+
...readinessFixes,
|
|
1217
|
+
"Create a new saved intent if the broader scope is intentional.",
|
|
1218
|
+
...input.nextSteps,
|
|
1219
|
+
]),
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function validationVerdict(input) {
|
|
1223
|
+
if (input.protectedContractChanges.length > 0 ||
|
|
1224
|
+
input.unplannedContractChanges.length > 0) {
|
|
1225
|
+
return "dangerous";
|
|
1226
|
+
}
|
|
1227
|
+
if (input.unplannedFiles.length > 0 || input.unplannedSymbols.length > 0) {
|
|
1228
|
+
return "drifted";
|
|
1229
|
+
}
|
|
1230
|
+
return "matched";
|
|
1231
|
+
}
|
|
1232
|
+
function repairStatus(validation) {
|
|
1233
|
+
if (validation.driftVerdict.status === "pass") {
|
|
1234
|
+
return "no-repair-needed";
|
|
1235
|
+
}
|
|
1236
|
+
if (validation.policyDrift.status === "changed") {
|
|
1237
|
+
return "human-review-required";
|
|
1238
|
+
}
|
|
1239
|
+
if (validation.readinessDrift.status === "weakened") {
|
|
1240
|
+
return "human-review-required";
|
|
1241
|
+
}
|
|
1242
|
+
if (validation.boundaryVerdict.status === "danger" &&
|
|
1243
|
+
validation.verdict !== "dangerous") {
|
|
1244
|
+
return "human-review-required";
|
|
1245
|
+
}
|
|
1246
|
+
if (validation.verdict === "dangerous") {
|
|
1247
|
+
return "contract-review-required";
|
|
1248
|
+
}
|
|
1249
|
+
return "repair-required";
|
|
1250
|
+
}
|
|
1251
|
+
function repairSummary(validation) {
|
|
1252
|
+
if (validation.driftVerdict.status === "pass") {
|
|
1253
|
+
return "Staged changes match the saved intent; no drift repair is needed.";
|
|
1254
|
+
}
|
|
1255
|
+
if (validation.policyDrift.status === "changed") {
|
|
1256
|
+
return "Current repo policy differs from the saved intent policy snapshot; ask the human before continuing.";
|
|
1257
|
+
}
|
|
1258
|
+
if (validation.readinessDrift.status === "weakened") {
|
|
1259
|
+
return "Current Ripple readiness is weaker than the saved intent readiness snapshot; restore readiness or ask the human before continuing.";
|
|
1260
|
+
}
|
|
1261
|
+
if (validation.boundaryVerdict.status === "danger" &&
|
|
1262
|
+
validation.verdict !== "dangerous") {
|
|
1263
|
+
return "Staged changes crossed a human-gated control boundary; ask the human before continuing.";
|
|
1264
|
+
}
|
|
1265
|
+
if (validation.verdict === "dangerous") {
|
|
1266
|
+
return "Staged changes include contract drift; review contracts before continuing.";
|
|
1267
|
+
}
|
|
1268
|
+
return "Staged changes drifted outside the saved intent; unstage extra files or create a new intent.";
|
|
1269
|
+
}
|
|
1270
|
+
function validationGuidance(input) {
|
|
1271
|
+
if (input.verdict === "matched") {
|
|
1272
|
+
return {
|
|
1273
|
+
recommendedAction: "Proceed with the saved plan and run the planned verification targets before handoff.",
|
|
1274
|
+
blockingReasons: [],
|
|
1275
|
+
nextSteps: [
|
|
1276
|
+
"Run the narrowest verification target(s) from the staged check.",
|
|
1277
|
+
"Keep the staged set scoped to the saved change intent.",
|
|
1278
|
+
],
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
const contextFilesChanged = new Set(input.contextFilesChanged);
|
|
1282
|
+
const outsideIntentFiles = input.unplannedFiles.filter((file) => !contextFilesChanged.has(file));
|
|
1283
|
+
if (input.verdict === "dangerous") {
|
|
1284
|
+
return {
|
|
1285
|
+
recommendedAction: "Stop and review contract drift; preserve the contract or create a new intent that explicitly allows the contract change.",
|
|
1286
|
+
blockingReasons: [
|
|
1287
|
+
...input.contextFilesChanged.map((file) => {
|
|
1288
|
+
return `Context-only file changed: ${file}`;
|
|
1289
|
+
}),
|
|
1290
|
+
...outsideIntentFiles.map((file) => {
|
|
1291
|
+
return `Unplanned file changed: ${file}`;
|
|
1292
|
+
}),
|
|
1293
|
+
...input.protectedContractChanges.map((symbol) => {
|
|
1294
|
+
return `Protected contract changed: ${symbol}`;
|
|
1295
|
+
}),
|
|
1296
|
+
...input.unplannedContractChanges.map((symbol) => {
|
|
1297
|
+
return `Unplanned contract changed: ${symbol}`;
|
|
1298
|
+
}),
|
|
1299
|
+
],
|
|
1300
|
+
nextSteps: [
|
|
1301
|
+
"Review callers for every contract-drift symbol.",
|
|
1302
|
+
"Either adjust the edit to preserve the contract or create a new intent for the broader contract change.",
|
|
1303
|
+
],
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
recommendedAction: "Unstage unplanned files or create a new intent that includes the broader scope before continuing.",
|
|
1308
|
+
blockingReasons: [
|
|
1309
|
+
...input.contextFilesChanged.map((file) => {
|
|
1310
|
+
return `Context-only file changed: ${file}`;
|
|
1311
|
+
}),
|
|
1312
|
+
...outsideIntentFiles.map((file) => {
|
|
1313
|
+
return `Unplanned file changed: ${file}`;
|
|
1314
|
+
}),
|
|
1315
|
+
...input.unplannedSymbols.map((symbol) => {
|
|
1316
|
+
return `Unplanned symbol changed: ${symbol}`;
|
|
1317
|
+
}),
|
|
1318
|
+
],
|
|
1319
|
+
nextSteps: [
|
|
1320
|
+
"Unstage files that are outside the saved edit scope.",
|
|
1321
|
+
"If the broader edit is intentional, create a new plan and save a new intent for that scope.",
|
|
1322
|
+
],
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
function validationReasons(input) {
|
|
1326
|
+
const reasons = [];
|
|
1327
|
+
const contextFilesChanged = new Set(input.contextFilesChanged);
|
|
1328
|
+
const outsideIntentFiles = input.unplannedFiles.filter((file) => !contextFilesChanged.has(file));
|
|
1329
|
+
if (input.contextFilesChanged.length > 0) {
|
|
1330
|
+
reasons.push(`${input.contextFilesChanged.length} context-only file(s) were edited`);
|
|
1331
|
+
}
|
|
1332
|
+
if (outsideIntentFiles.length > 0) {
|
|
1333
|
+
reasons.push(`${outsideIntentFiles.length} staged file(s) were outside the saved plan`);
|
|
1334
|
+
}
|
|
1335
|
+
if (input.unplannedSymbols.length > 0) {
|
|
1336
|
+
reasons.push(`${input.unplannedSymbols.length} changed symbol(s) were outside expected symbol focus`);
|
|
1337
|
+
}
|
|
1338
|
+
if (input.protectedContractChanges.length > 0) {
|
|
1339
|
+
reasons.push(`${input.protectedContractChanges.length} protected contract(s) changed`);
|
|
1340
|
+
}
|
|
1341
|
+
if (input.unplannedContractChanges.length > 0) {
|
|
1342
|
+
reasons.push(`${input.unplannedContractChanges.length} unplanned contract change(s) found`);
|
|
1343
|
+
}
|
|
1344
|
+
if (reasons.length === 0) {
|
|
1345
|
+
reasons.push("staged changes stayed inside the saved change intent");
|
|
1346
|
+
}
|
|
1347
|
+
return reasons;
|
|
1348
|
+
}
|
|
1349
|
+
function contractChangedSymbols(symbols) {
|
|
1350
|
+
return symbols.filter((symbol) => {
|
|
1351
|
+
return symbol.symbolStatus !== "created" && symbol.contractChanged;
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
function changeIntentEditableFiles(intent) {
|
|
1355
|
+
return uniqueItems(intent.editableFiles && intent.editableFiles.length > 0
|
|
1356
|
+
? intent.editableFiles
|
|
1357
|
+
: intent.expectedFiles.length > 0
|
|
1358
|
+
? intent.expectedFiles
|
|
1359
|
+
: [intent.targetFile]);
|
|
1360
|
+
}
|
|
1361
|
+
function changeIntentContextFiles(intent, editableFiles) {
|
|
1362
|
+
const editableFileSet = new Set(editableFiles);
|
|
1363
|
+
const contextFiles = intent.contextFiles && intent.contextFiles.length > 0
|
|
1364
|
+
? intent.contextFiles
|
|
1365
|
+
: intent.allowedFiles.filter((file) => !editableFileSet.has(file));
|
|
1366
|
+
return uniqueItems(contextFiles.filter((file) => !editableFileSet.has(file)));
|
|
1367
|
+
}
|
|
1368
|
+
function resolveIntentPath(workspaceRoot, intentPath) {
|
|
1369
|
+
const normalized = intentPath.trim();
|
|
1370
|
+
if (normalized.length === 0 || normalized === "latest") {
|
|
1371
|
+
return defaultChangeIntentPath(workspaceRoot);
|
|
1372
|
+
}
|
|
1373
|
+
if (path.isAbsolute(normalized)) {
|
|
1374
|
+
return normalized;
|
|
1375
|
+
}
|
|
1376
|
+
if (normalized.endsWith(".json") || normalized.includes("/") || normalized.includes("\\")) {
|
|
1377
|
+
return path.resolve(workspaceRoot, normalized);
|
|
1378
|
+
}
|
|
1379
|
+
return path.join(workspaceRoot, INTENTS_DIR, `${normalized}.json`);
|
|
1380
|
+
}
|
|
1381
|
+
function assertChangeIntent(value, sourcePath) {
|
|
1382
|
+
if (!isRecord(value)) {
|
|
1383
|
+
throw new Error(`Invalid Ripple change intent: ${sourcePath}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (value.protocol !== INTENT_PROTOCOL || value.version !== INTENT_VERSION) {
|
|
1386
|
+
throw new Error(`Unsupported Ripple change intent: ${sourcePath}`);
|
|
1387
|
+
}
|
|
1388
|
+
if (typeof value.id !== "string" ||
|
|
1389
|
+
typeof value.createdAt !== "string" ||
|
|
1390
|
+
typeof value.task !== "string" ||
|
|
1391
|
+
typeof value.targetFile !== "string" ||
|
|
1392
|
+
typeof value.tokenBudget !== "number" ||
|
|
1393
|
+
!Array.isArray(value.allowedFiles) ||
|
|
1394
|
+
!Array.isArray(value.expectedFiles) ||
|
|
1395
|
+
(value.editableFiles !== undefined && !Array.isArray(value.editableFiles)) ||
|
|
1396
|
+
(value.contextFiles !== undefined && !Array.isArray(value.contextFiles)) ||
|
|
1397
|
+
!Array.isArray(value.expectedSymbols) ||
|
|
1398
|
+
!Array.isArray(value.protectedContracts) ||
|
|
1399
|
+
!Array.isArray(value.verificationTargets) ||
|
|
1400
|
+
typeof value.why !== "string") {
|
|
1401
|
+
throw new Error(`Malformed Ripple change intent: ${sourcePath}`);
|
|
1402
|
+
}
|
|
1403
|
+
return normalizeChangeIntent(value);
|
|
1404
|
+
}
|
|
1405
|
+
function normalizeChangeIntent(intent) {
|
|
1406
|
+
const editableFiles = uniqueItems(intent.editableFiles && intent.editableFiles.length > 0
|
|
1407
|
+
? intent.editableFiles
|
|
1408
|
+
: intent.expectedFiles.length > 0
|
|
1409
|
+
? intent.expectedFiles
|
|
1410
|
+
: [intent.targetFile]);
|
|
1411
|
+
const editableFileSet = new Set(editableFiles);
|
|
1412
|
+
const contextFiles = uniqueItems((intent.contextFiles && intent.contextFiles.length > 0
|
|
1413
|
+
? intent.contextFiles
|
|
1414
|
+
: intent.allowedFiles.filter((file) => !editableFileSet.has(file))).filter((file) => !editableFileSet.has(file)));
|
|
1415
|
+
const controlMode = isControlMode(intent.controlMode) ? intent.controlMode : "file";
|
|
1416
|
+
const allowedSymbols = uniqueItems((intent.allowedSymbols ?? []).filter((symbol) => typeof symbol === "string" && symbol.trim().length > 0));
|
|
1417
|
+
const boundaryRisk = isControlBoundaryRisk(intent.boundaryRisk)
|
|
1418
|
+
? intent.boundaryRisk
|
|
1419
|
+
: riskFromPath(intent.targetFile);
|
|
1420
|
+
const humanGate = isHumanGate(intent.humanGate) ? intent.humanGate : "none";
|
|
1421
|
+
const humanGateReason = (intent.humanGateReason ?? []).filter((reason) => typeof reason === "string" && reason.trim().length > 0);
|
|
1422
|
+
const policySource = typeof intent.policySource === "string"
|
|
1423
|
+
? intent.policySource
|
|
1424
|
+
: "legacy-intent";
|
|
1425
|
+
const policyMatches = (intent.policyMatches ?? []).filter((match) => typeof match === "string" && match.trim().length > 0);
|
|
1426
|
+
const policyExplanation = normalizePolicyExplanationSnapshot(intent.policyExplanation, {
|
|
1427
|
+
targetFile: intent.targetFile,
|
|
1428
|
+
controlMode,
|
|
1429
|
+
boundaryRisk,
|
|
1430
|
+
humanGate,
|
|
1431
|
+
policySource,
|
|
1432
|
+
policyMatches,
|
|
1433
|
+
policyRisk: policySource !== "built-in default" && policySource !== "legacy-intent"
|
|
1434
|
+
? boundaryRisk
|
|
1435
|
+
: "none",
|
|
1436
|
+
});
|
|
1437
|
+
const readinessSnapshot = normalizeReadinessSnapshot(intent.readinessSnapshot);
|
|
1438
|
+
return {
|
|
1439
|
+
...intent,
|
|
1440
|
+
controlMode,
|
|
1441
|
+
allowedSymbols,
|
|
1442
|
+
humanGate,
|
|
1443
|
+
humanGateReason,
|
|
1444
|
+
boundaryRisk,
|
|
1445
|
+
policySource,
|
|
1446
|
+
policyMatches,
|
|
1447
|
+
policyExplanation,
|
|
1448
|
+
editableFiles,
|
|
1449
|
+
contextFiles,
|
|
1450
|
+
allowedFiles: uniqueItems([...editableFiles, ...contextFiles]),
|
|
1451
|
+
expectedFiles: uniqueItems(intent.expectedFiles.length > 0 ? intent.expectedFiles : editableFiles),
|
|
1452
|
+
readinessSnapshot,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
function normalizeReadinessSnapshot(value) {
|
|
1456
|
+
if (!isRecord(value)) {
|
|
1457
|
+
return fallbackReadinessSnapshot();
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
status: isReadinessStatus(value.status) ? value.status : "needs_setup",
|
|
1461
|
+
enforcementLevel: isRippleEnforcementLevel(value.enforcementLevel)
|
|
1462
|
+
? value.enforcementLevel
|
|
1463
|
+
: "none",
|
|
1464
|
+
canGuideAgents: typeof value.canGuideAgents === "boolean" ? value.canGuideAgents : false,
|
|
1465
|
+
canDetectDrift: typeof value.canDetectDrift === "boolean" ? value.canDetectDrift : false,
|
|
1466
|
+
canBlockInCi: typeof value.canBlockInCi === "boolean" ? value.canBlockInCi : false,
|
|
1467
|
+
policyExplicit: typeof value.policyExplicit === "boolean" ? value.policyExplicit : false,
|
|
1468
|
+
graphOk: typeof value.graphOk === "boolean" ? value.graphOk : false,
|
|
1469
|
+
gitOk: typeof value.gitOk === "boolean" ? value.gitOk : false,
|
|
1470
|
+
ciWorkflowOk: typeof value.ciWorkflowOk === "boolean" ? value.ciWorkflowOk : false,
|
|
1471
|
+
latestIntentOk: typeof value.latestIntentOk === "boolean" ? value.latestIntentOk : false,
|
|
1472
|
+
gaps: stringList(value.gaps),
|
|
1473
|
+
nextSteps: stringList(value.nextSteps),
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
function fallbackReadinessSnapshot() {
|
|
1477
|
+
return {
|
|
1478
|
+
status: "needs_setup",
|
|
1479
|
+
enforcementLevel: "none",
|
|
1480
|
+
canGuideAgents: false,
|
|
1481
|
+
canDetectDrift: false,
|
|
1482
|
+
canBlockInCi: false,
|
|
1483
|
+
policyExplicit: false,
|
|
1484
|
+
graphOk: false,
|
|
1485
|
+
gitOk: false,
|
|
1486
|
+
ciWorkflowOk: false,
|
|
1487
|
+
latestIntentOk: false,
|
|
1488
|
+
gaps: ["Readiness snapshot was not captured when this intent was saved."],
|
|
1489
|
+
nextSteps: ["Run ripple doctor --agent."],
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
function isReadinessStatus(value) {
|
|
1493
|
+
return value === "ready" || value === "needs_setup";
|
|
1494
|
+
}
|
|
1495
|
+
function isRippleEnforcementLevel(value) {
|
|
1496
|
+
return (value === "none" ||
|
|
1497
|
+
value === "advisory" ||
|
|
1498
|
+
value === "drift-check-ready" ||
|
|
1499
|
+
value === "ci-gate-ready");
|
|
1500
|
+
}
|
|
1501
|
+
function normalizePolicyExplanationSnapshot(value, defaults) {
|
|
1502
|
+
const raw = isRecord(value) ? value : undefined;
|
|
1503
|
+
const rawMatchedRules = raw ? stringList(raw.matchedRules) : [];
|
|
1504
|
+
const matchedRules = raw && Array.isArray(raw.matchedRules)
|
|
1505
|
+
? rawMatchedRules
|
|
1506
|
+
: defaults.policyMatches;
|
|
1507
|
+
const rawWhy = raw ? stringList(raw.why) : [];
|
|
1508
|
+
const rawNextSteps = raw ? stringList(raw.nextSteps) : [];
|
|
1509
|
+
const rawPolicySource = raw?.policySource;
|
|
1510
|
+
const policySource = typeof rawPolicySource === "string" && rawPolicySource.trim().length > 0
|
|
1511
|
+
? rawPolicySource
|
|
1512
|
+
: defaults.policySource;
|
|
1513
|
+
const rawPolicyRisk = raw?.policyRisk;
|
|
1514
|
+
return {
|
|
1515
|
+
protocol: "ripple-policy-explanation",
|
|
1516
|
+
version: 1,
|
|
1517
|
+
targetFile: defaults.targetFile,
|
|
1518
|
+
policySource,
|
|
1519
|
+
policyExists: typeof raw?.policyExists === "boolean"
|
|
1520
|
+
? raw.policyExists
|
|
1521
|
+
: policySource !== "built-in default" && policySource !== "legacy-intent",
|
|
1522
|
+
effectiveMode: defaults.controlMode,
|
|
1523
|
+
policyRisk: isPolicyExplanationRisk(rawPolicyRisk)
|
|
1524
|
+
? rawPolicyRisk
|
|
1525
|
+
: defaults.policyRisk,
|
|
1526
|
+
humanGate: defaults.humanGate,
|
|
1527
|
+
humanRequired: defaults.humanGate !== "none",
|
|
1528
|
+
allowPrMode: typeof raw?.allowPrMode === "boolean" ? raw.allowPrMode : false,
|
|
1529
|
+
matchedRules,
|
|
1530
|
+
why: rawWhy.length > 0 ? rawWhy : fallbackPolicyExplanationWhy(defaults),
|
|
1531
|
+
nextSteps: rawNextSteps.length > 0
|
|
1532
|
+
? rawNextSteps
|
|
1533
|
+
: fallbackPolicyExplanationNextSteps(defaults.humanGate),
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
function fallbackPolicyExplanationWhy(defaults) {
|
|
1537
|
+
const why = [
|
|
1538
|
+
`Saved control mode: ${defaults.controlMode}.`,
|
|
1539
|
+
`Policy source at plan time: ${defaults.policySource}.`,
|
|
1540
|
+
];
|
|
1541
|
+
if (defaults.policyRisk !== "none") {
|
|
1542
|
+
why.push(`Policy risk at plan time: ${defaults.policyRisk}.`);
|
|
1543
|
+
}
|
|
1544
|
+
if (defaults.policyMatches.length > 0) {
|
|
1545
|
+
why.push(`Matched policy rules at plan time: ${defaults.policyMatches.join("; ")}.`);
|
|
1546
|
+
}
|
|
1547
|
+
return why;
|
|
1548
|
+
}
|
|
1549
|
+
function fallbackPolicyExplanationNextSteps(humanGate) {
|
|
1550
|
+
if (humanGate === "required-before-edit") {
|
|
1551
|
+
return ["Ask the human to approve before the agent edits this file."];
|
|
1552
|
+
}
|
|
1553
|
+
if (humanGate === "required-before-merge") {
|
|
1554
|
+
return ["Require human review before merging this change."];
|
|
1555
|
+
}
|
|
1556
|
+
return ["Check staged changes against this saved intent before handoff."];
|
|
1557
|
+
}
|
|
1558
|
+
function stringList(value) {
|
|
1559
|
+
if (!Array.isArray(value)) {
|
|
1560
|
+
return [];
|
|
1561
|
+
}
|
|
1562
|
+
return uniqueItems(value.filter((item) => typeof item === "string" && item.trim().length > 0));
|
|
1563
|
+
}
|
|
1564
|
+
function editableFilesForControlMode(plan, controlMode, options) {
|
|
1565
|
+
const explicitFiles = uniqueItems(options.allowedFiles ?? []);
|
|
1566
|
+
if (controlMode === "brainstorm") {
|
|
1567
|
+
return [];
|
|
1568
|
+
}
|
|
1569
|
+
if (controlMode === "task" || controlMode === "pr") {
|
|
1570
|
+
return explicitFiles.length > 0
|
|
1571
|
+
? explicitFiles
|
|
1572
|
+
: [plan.targetFile];
|
|
1573
|
+
}
|
|
1574
|
+
return [plan.targetFile];
|
|
1575
|
+
}
|
|
1576
|
+
function allowedSymbolsForControlMode(plan, controlMode, options) {
|
|
1577
|
+
const allowedSymbols = uniqueItems((options.allowedSymbols ?? [])
|
|
1578
|
+
.map((symbol) => normalizeAllowedSymbol(plan.targetFile, symbol))
|
|
1579
|
+
.filter(Boolean));
|
|
1580
|
+
if (controlMode === "function" && allowedSymbols.length === 0) {
|
|
1581
|
+
throw new Error("Function control mode requires --symbol or allowedSymbols.");
|
|
1582
|
+
}
|
|
1583
|
+
return allowedSymbols;
|
|
1584
|
+
}
|
|
1585
|
+
function normalizeAllowedSymbol(targetFile, symbol) {
|
|
1586
|
+
const trimmed = symbol.trim();
|
|
1587
|
+
if (!trimmed) {
|
|
1588
|
+
return "";
|
|
1589
|
+
}
|
|
1590
|
+
return trimmed.includes("::") ? trimmed : `${targetFile}::${trimmed}`;
|
|
1591
|
+
}
|
|
1592
|
+
function controlBoundaryRisk(plan) {
|
|
1593
|
+
const pathRisk = riskFromPath(plan.targetFile);
|
|
1594
|
+
if (pathRisk === "critical" || pathRisk === "high") {
|
|
1595
|
+
return pathRisk;
|
|
1596
|
+
}
|
|
1597
|
+
if (plan.risk === "dangerous") {
|
|
1598
|
+
return "high";
|
|
1599
|
+
}
|
|
1600
|
+
if (plan.risk === "caution") {
|
|
1601
|
+
return "medium";
|
|
1602
|
+
}
|
|
1603
|
+
return pathRisk;
|
|
1604
|
+
}
|
|
1605
|
+
function strongestBoundaryRisk(baseRisk, policyRisk) {
|
|
1606
|
+
if (!policyRisk) {
|
|
1607
|
+
return baseRisk;
|
|
1608
|
+
}
|
|
1609
|
+
return boundaryRiskRank(policyRisk) > boundaryRiskRank(baseRisk) ? policyRisk : baseRisk;
|
|
1610
|
+
}
|
|
1611
|
+
function boundaryRiskRank(risk) {
|
|
1612
|
+
if (risk === "critical") {
|
|
1613
|
+
return 3;
|
|
1614
|
+
}
|
|
1615
|
+
if (risk === "high") {
|
|
1616
|
+
return 2;
|
|
1617
|
+
}
|
|
1618
|
+
if (risk === "medium") {
|
|
1619
|
+
return 1;
|
|
1620
|
+
}
|
|
1621
|
+
return 0;
|
|
1622
|
+
}
|
|
1623
|
+
function humanGateForPlan(plan, controlMode, boundaryRisk, policy) {
|
|
1624
|
+
if (policy?.requireHumanBeforeEdit) {
|
|
1625
|
+
return "required-before-edit";
|
|
1626
|
+
}
|
|
1627
|
+
if (controlMode === "brainstorm") {
|
|
1628
|
+
return "required-before-edit";
|
|
1629
|
+
}
|
|
1630
|
+
if (boundaryRisk === "critical" || boundaryRisk === "high" || plan.risk === "dangerous") {
|
|
1631
|
+
return "required-before-edit";
|
|
1632
|
+
}
|
|
1633
|
+
if (policy?.requireHumanBeforeMerge) {
|
|
1634
|
+
return "required-before-merge";
|
|
1635
|
+
}
|
|
1636
|
+
if (controlMode === "pr") {
|
|
1637
|
+
return "required-before-merge";
|
|
1638
|
+
}
|
|
1639
|
+
return "none";
|
|
1640
|
+
}
|
|
1641
|
+
function humanGateReasons(plan, controlMode, boundaryRisk, policy) {
|
|
1642
|
+
const reasons = [];
|
|
1643
|
+
if (policy?.source === "file") {
|
|
1644
|
+
reasons.push(`Trust policy loaded from ${policy.sourcePath ?? ".ripple/policy.json"}.`);
|
|
1645
|
+
}
|
|
1646
|
+
if (policy?.matchedRules.length) {
|
|
1647
|
+
reasons.push(`Trust policy matched: ${policy.matchedRules.join("; ")}.`);
|
|
1648
|
+
}
|
|
1649
|
+
if (policy?.requireHumanBeforeEdit) {
|
|
1650
|
+
reasons.push("Trust policy requires human approval before editing this path.");
|
|
1651
|
+
}
|
|
1652
|
+
if (policy?.requireHumanBeforeMerge) {
|
|
1653
|
+
reasons.push("Trust policy requires human approval before merge.");
|
|
1654
|
+
}
|
|
1655
|
+
if (controlMode === "brainstorm") {
|
|
1656
|
+
reasons.push("Brainstorm mode does not allow file edits.");
|
|
1657
|
+
}
|
|
1658
|
+
if (boundaryRisk === "critical" || boundaryRisk === "high") {
|
|
1659
|
+
reasons.push(`Target path is ${boundaryRisk} risk for agent autonomy.`);
|
|
1660
|
+
}
|
|
1661
|
+
if (plan.risk === "dangerous") {
|
|
1662
|
+
reasons.push("Ripple graph marks the target as dangerous because of blast radius or churn.");
|
|
1663
|
+
}
|
|
1664
|
+
if (controlMode === "pr") {
|
|
1665
|
+
reasons.push("PR mode still requires human review before merge.");
|
|
1666
|
+
}
|
|
1667
|
+
return uniqueItems(reasons);
|
|
1668
|
+
}
|
|
1669
|
+
function policySourceLabel(policy) {
|
|
1670
|
+
if (!policy) {
|
|
1671
|
+
return "built-in default";
|
|
1672
|
+
}
|
|
1673
|
+
if (policy.source === "file") {
|
|
1674
|
+
return policy.sourcePath ?? ".ripple/policy.json";
|
|
1675
|
+
}
|
|
1676
|
+
return "built-in default";
|
|
1677
|
+
}
|
|
1678
|
+
function riskFromPath(filePath) {
|
|
1679
|
+
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
1680
|
+
const segments = normalized.split("/");
|
|
1681
|
+
const hasSegment = (names) => {
|
|
1682
|
+
return segments.some((segment) => names.includes(segment));
|
|
1683
|
+
};
|
|
1684
|
+
if (hasSegment([
|
|
1685
|
+
"payment",
|
|
1686
|
+
"payments",
|
|
1687
|
+
"billing",
|
|
1688
|
+
"migrations",
|
|
1689
|
+
"migration",
|
|
1690
|
+
"database",
|
|
1691
|
+
"db",
|
|
1692
|
+
"schema",
|
|
1693
|
+
"secrets",
|
|
1694
|
+
"secret",
|
|
1695
|
+
"deploy",
|
|
1696
|
+
"deployment",
|
|
1697
|
+
"infra",
|
|
1698
|
+
"terraform",
|
|
1699
|
+
".github",
|
|
1700
|
+
"ci",
|
|
1701
|
+
]) ||
|
|
1702
|
+
/(^|[/.])(schema|migration|billing|payment|secret|deploy)([/.]|$)/i.test(normalized)) {
|
|
1703
|
+
return "critical";
|
|
1704
|
+
}
|
|
1705
|
+
if (/(^|[/.])(auth|security|session|token|permission|permissions|role|roles|acl|oauth|jwt)([/.]|$)/i
|
|
1706
|
+
.test(normalized)) {
|
|
1707
|
+
return "high";
|
|
1708
|
+
}
|
|
1709
|
+
return "low";
|
|
1710
|
+
}
|
|
1711
|
+
function assertControlMode(value) {
|
|
1712
|
+
if (!isControlMode(value)) {
|
|
1713
|
+
throw new Error(`Unsupported control mode: ${String(value)}`);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function isControlMode(value) {
|
|
1717
|
+
return (value === "brainstorm" ||
|
|
1718
|
+
value === "function" ||
|
|
1719
|
+
value === "file" ||
|
|
1720
|
+
value === "task" ||
|
|
1721
|
+
value === "pr");
|
|
1722
|
+
}
|
|
1723
|
+
function isHumanGate(value) {
|
|
1724
|
+
return (value === "none" ||
|
|
1725
|
+
value === "required-before-edit" ||
|
|
1726
|
+
value === "required-before-merge");
|
|
1727
|
+
}
|
|
1728
|
+
function isControlBoundaryRisk(value) {
|
|
1729
|
+
return value === "low" || value === "medium" || value === "high" || value === "critical";
|
|
1730
|
+
}
|
|
1731
|
+
function isPolicyExplanationRisk(value) {
|
|
1732
|
+
return value === "none" || isControlBoundaryRisk(value);
|
|
1733
|
+
}
|
|
1734
|
+
function makeIntentId(plan, createdAt) {
|
|
1735
|
+
const hash = crypto
|
|
1736
|
+
.createHash("sha1")
|
|
1737
|
+
.update(`${createdAt}:${plan.targetFile}:${plan.task}`)
|
|
1738
|
+
.digest("hex")
|
|
1739
|
+
.slice(0, 10);
|
|
1740
|
+
return `intent-${Date.now().toString(36)}-${hash}`;
|
|
1741
|
+
}
|
|
1742
|
+
function isSourceFilePath(value) {
|
|
1743
|
+
return SOURCE_FILE_RE.test(value) && !value.endsWith(".d.ts");
|
|
1744
|
+
}
|
|
1745
|
+
function uniqueItems(items) {
|
|
1746
|
+
const seen = new Set();
|
|
1747
|
+
return items.filter((item) => {
|
|
1748
|
+
if (seen.has(item)) {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
seen.add(item);
|
|
1752
|
+
return true;
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
function isRecord(value) {
|
|
1756
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1757
|
+
}
|
|
1758
|
+
//# sourceMappingURL=change-intent.js.map
|