@deftai/directive-core 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cache/archive.js +10 -4
- package/dist/check/gate-lists.js +8 -0
- package/dist/consumer-check-contract/evaluate.d.ts +124 -0
- package/dist/consumer-check-contract/evaluate.js +699 -0
- package/dist/consumer-check-contract/index.d.ts +5 -0
- package/dist/consumer-check-contract/index.js +5 -0
- package/dist/delivery-attempt/disk-begin.d.ts +51 -0
- package/dist/delivery-attempt/disk-begin.js +68 -0
- package/dist/delivery-attempt/evaluate.d.ts +26 -0
- package/dist/delivery-attempt/evaluate.js +443 -0
- package/dist/delivery-attempt/fingerprint.d.ts +32 -0
- package/dist/delivery-attempt/fingerprint.js +100 -0
- package/dist/delivery-attempt/handoff.d.ts +25 -0
- package/dist/delivery-attempt/handoff.js +102 -0
- package/dist/delivery-attempt/index.d.ts +17 -0
- package/dist/delivery-attempt/index.js +17 -0
- package/dist/delivery-attempt/ledger.d.ts +169 -0
- package/dist/delivery-attempt/ledger.js +758 -0
- package/dist/delivery-attempt/material-delta.d.ts +38 -0
- package/dist/delivery-attempt/material-delta.js +126 -0
- package/dist/delivery-attempt/types.d.ts +210 -0
- package/dist/delivery-attempt/types.js +77 -0
- package/dist/doctor/index.d.ts +1 -0
- package/dist/doctor/index.js +1 -0
- package/dist/doctor/main.js +12 -0
- package/dist/doctor/openclaw-soft-rebind.d.ts +26 -0
- package/dist/doctor/openclaw-soft-rebind.js +164 -0
- package/dist/hooks/dispatcher.d.ts +2 -1
- package/dist/hooks/dispatcher.js +63 -9
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/init-deposit/gitignore.js +7 -0
- package/dist/init-deposit/init-deposit.js +5 -0
- package/dist/init-deposit/refresh.js +3 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +29 -1
- package/dist/pr-merge-readiness/ci-gate.js +191 -24
- package/dist/pr-merge-readiness/compute.js +10 -1
- package/dist/pr-merge-readiness/index.d.ts +2 -1
- package/dist/pr-merge-readiness/index.js +2 -1
- package/dist/pr-merge-readiness/output.js +14 -0
- package/dist/pr-merge-readiness/platform-status.d.ts +29 -0
- package/dist/pr-merge-readiness/platform-status.js +49 -0
- package/dist/pr-watch/constants.d.ts +10 -0
- package/dist/pr-watch/constants.js +12 -1
- package/dist/pr-watch/main.js +16 -1
- package/dist/pr-watch/probe.js +13 -1
- package/dist/pr-watch/types.d.ts +3 -2
- package/dist/pr-watch/watch.js +14 -7
- package/dist/scope-provenance/digest.d.ts +67 -0
- package/dist/scope-provenance/digest.js +188 -0
- package/dist/scope-provenance/evaluate.d.ts +82 -0
- package/dist/scope-provenance/evaluate.js +528 -0
- package/dist/scope-provenance/index.d.ts +6 -0
- package/dist/scope-provenance/index.js +6 -0
- package/dist/session/compact-ritual.d.ts +96 -0
- package/dist/session/compact-ritual.js +237 -0
- package/dist/session/compact-ritual.spec.d.ts +2 -0
- package/dist/session/compact-ritual.spec.js +21 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/openclaw-soft-rebind-deposit.d.ts +46 -0
- package/dist/session/openclaw-soft-rebind-deposit.js +165 -0
- package/dist/test-boundary/evaluate.d.ts +54 -0
- package/dist/test-boundary/evaluate.js +368 -0
- package/dist/test-boundary/index.d.ts +6 -0
- package/dist/test-boundary/index.js +6 -0
- package/dist/test-boundary/policy.d.ts +52 -0
- package/dist/test-boundary/policy.js +182 -0
- package/dist/triage/bootstrap/gitignore.d.ts +1 -1
- package/dist/triage/bootstrap/gitignore.js +15 -1
- package/dist/vbrief-activate/activate.js +22 -6
- package/dist/xbrief/styles.js +33 -17
- package/package.json +7 -3
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verify:scope-provenance evaluation (#3145).
|
|
3
|
+
*
|
|
4
|
+
* Detects same-PR active xBRIEF expansion that would self-authorize new
|
|
5
|
+
* implementation paths. Requires renewed human approval (updated digest record
|
|
6
|
+
* with human stamp); the modified xBRIEF alone is never sufficient.
|
|
7
|
+
*
|
|
8
|
+
* Three-state exit: 0 clean / 1 self-authorization / 2 config.
|
|
9
|
+
* Migration: missing approved-scope records → warn (exit 0) unless --enforce.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { GitCommandError, GitNotFoundError } from "../encoding/git.js";
|
|
15
|
+
import { computeFileScopeDigest, extractFileScope, extractPlanId, isHumanApprovalStamp, listApprovedScopeRecords, normalizeFileScope, readApprovedScopeRecord, scopeExpansion, } from "./digest.js";
|
|
16
|
+
function git(args, projectRoot) {
|
|
17
|
+
const result = spawnSync("git", args, {
|
|
18
|
+
cwd: projectRoot,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21
|
+
});
|
|
22
|
+
if (result.error !== undefined) {
|
|
23
|
+
const e = result.error;
|
|
24
|
+
if (e.code === "ENOENT") {
|
|
25
|
+
throw new GitNotFoundError("'git' executable not found on PATH");
|
|
26
|
+
}
|
|
27
|
+
throw new GitCommandError(`git ${args.join(" ")} failed: ${String(e.message)}`);
|
|
28
|
+
}
|
|
29
|
+
// Signal-killed subprocesses report status=null; never treat as clean exit (SLizard P1).
|
|
30
|
+
if (result.signal !== null && result.signal !== undefined) {
|
|
31
|
+
throw new GitCommandError(`git ${args.join(" ")} killed by signal ${String(result.signal)}`);
|
|
32
|
+
}
|
|
33
|
+
const status = result.status ?? 1;
|
|
34
|
+
const stderr = String(result.stderr ?? "").trim();
|
|
35
|
+
// Surface stderr on failure so callers can distinguish "not a git repository".
|
|
36
|
+
if (status !== 0 && stderr.length > 0) {
|
|
37
|
+
return { status, stdout: `${result.stdout ?? ""}\n${stderr}` };
|
|
38
|
+
}
|
|
39
|
+
return { status, stdout: result.stdout ?? "" };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a PR-aware base ref. Bare `HEAD` only shows uncommitted changes, so
|
|
43
|
+
* CI/PR checkouts would miss committed active-xBRIEF expansion. Prefer
|
|
44
|
+
* origin/master (or main) for merge-base comparison.
|
|
45
|
+
* Returns null when no merge-base candidate exists (caller fails closed).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveDefaultBaseRef(projectRoot) {
|
|
48
|
+
const envCandidates = [
|
|
49
|
+
process.env.DEFT_BASE_REF,
|
|
50
|
+
process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : undefined,
|
|
51
|
+
process.env.GITHUB_BASE_REF,
|
|
52
|
+
].filter((x) => typeof x === "string" && x.trim().length > 0);
|
|
53
|
+
for (const cand of [...envCandidates, "origin/master", "origin/main", "master", "main"]) {
|
|
54
|
+
if (git(["rev-parse", "--verify", "-q", cand], projectRoot).status === 0) {
|
|
55
|
+
return cand;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Normalize git --name-only paths (including C-quoted paths).
|
|
62
|
+
* Decode C-quotes BEFORE converting remaining backslashes to `/` so escape
|
|
63
|
+
* sequences are not destroyed. Consecutive octal escapes decode as UTF-8
|
|
64
|
+
* bytes (e.g. \\303\\251 → é) so Unicode xBRIEF paths match filesystem names.
|
|
65
|
+
*/
|
|
66
|
+
export function unquoteGitPath(raw) {
|
|
67
|
+
const t = raw.replace(/\r$/, "").trim();
|
|
68
|
+
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
|
|
69
|
+
let inner = t.slice(1, -1);
|
|
70
|
+
// First: runs of octal bytes → UTF-8 (Git encodes non-ASCII this way)
|
|
71
|
+
inner = inner.replace(/(?:\\[0-7]{1,3})+/g, (seq) => {
|
|
72
|
+
const bytes = [];
|
|
73
|
+
for (const m of seq.matchAll(/\\([0-7]{1,3})/g)) {
|
|
74
|
+
bytes.push(parseInt(m[1] ?? "0", 8));
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return Buffer.from(bytes).toString("utf8");
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return String.fromCharCode(...bytes);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// Then: standard single-char escapes
|
|
84
|
+
inner = inner.replace(/\\([abtnvfr"'\\])/g, (_m, ch) => {
|
|
85
|
+
const map = {
|
|
86
|
+
a: "\x07",
|
|
87
|
+
b: "\b",
|
|
88
|
+
t: "\t",
|
|
89
|
+
n: "\n",
|
|
90
|
+
v: "\v",
|
|
91
|
+
f: "\f",
|
|
92
|
+
r: "\r",
|
|
93
|
+
'"': '"',
|
|
94
|
+
"'": "'",
|
|
95
|
+
"\\": "\\",
|
|
96
|
+
};
|
|
97
|
+
return map[ch] ?? ch;
|
|
98
|
+
});
|
|
99
|
+
return inner.replace(/\\/g, "/");
|
|
100
|
+
}
|
|
101
|
+
return t.replace(/\\/g, "/");
|
|
102
|
+
}
|
|
103
|
+
/** Read a repo-relative path as of `ref` (null if missing). */
|
|
104
|
+
function readRepoFileAtRef(projectRoot, ref, relPath) {
|
|
105
|
+
const path = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
106
|
+
const result = git(["show", `${ref}:${path}`], projectRoot);
|
|
107
|
+
if (result.status !== 0)
|
|
108
|
+
return null;
|
|
109
|
+
return result.stdout;
|
|
110
|
+
}
|
|
111
|
+
function changedFilesVsBase(projectRoot, baseRef) {
|
|
112
|
+
const inside = git(["rev-parse", "--is-inside-work-tree"], projectRoot);
|
|
113
|
+
if (inside.status !== 0) {
|
|
114
|
+
throw new GitCommandError("not a git working tree");
|
|
115
|
+
}
|
|
116
|
+
// Normalize: if caller passed HEAD, upgrade to default branch so PR commits
|
|
117
|
+
// are visible (Greptile P1 / #3145). Never silently fall back to bare HEAD.
|
|
118
|
+
let resolved = baseRef;
|
|
119
|
+
if (baseRef === "HEAD" || baseRef === "") {
|
|
120
|
+
const upgraded = resolveDefaultBaseRef(projectRoot);
|
|
121
|
+
if (upgraded === null) {
|
|
122
|
+
throw new GitCommandError("no merge-base ref (origin/master|main or DEFT_BASE_REF/GITHUB_BASE_REF); " +
|
|
123
|
+
"cannot evaluate committed PR scope expansion against bare HEAD");
|
|
124
|
+
}
|
|
125
|
+
resolved = upgraded;
|
|
126
|
+
}
|
|
127
|
+
const hasBase = git(["rev-parse", "--verify", "-q", resolved], projectRoot).status === 0;
|
|
128
|
+
if (!hasBase) {
|
|
129
|
+
throw new GitCommandError(`base ref '${resolved}' not found; pass --base-ref or set DEFT_BASE_REF`);
|
|
130
|
+
}
|
|
131
|
+
const out = new Set();
|
|
132
|
+
const addPath = (raw) => {
|
|
133
|
+
const t = normalizeRepoRelPath(unquoteGitPath(raw));
|
|
134
|
+
if (t.length > 0)
|
|
135
|
+
out.add(t);
|
|
136
|
+
};
|
|
137
|
+
// Triple-dot includes all commits on the branch relative to merge-base.
|
|
138
|
+
const range = resolved === "HEAD" || resolved.includes("...") ? resolved : `${resolved}...HEAD`;
|
|
139
|
+
const diff = git(["diff", "--name-only", range], projectRoot);
|
|
140
|
+
if (diff.status === 0) {
|
|
141
|
+
for (const line of diff.stdout.split("\n")) {
|
|
142
|
+
addPath(line);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Also include working-tree changes vs HEAD (unstaged / staged / untracked).
|
|
146
|
+
const vsHead = git(["diff", "--name-only", "HEAD"], projectRoot);
|
|
147
|
+
if (vsHead.status === 0) {
|
|
148
|
+
for (const line of vsHead.stdout.split("\n")) {
|
|
149
|
+
addPath(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard"], projectRoot);
|
|
153
|
+
if (untracked.status === 0) {
|
|
154
|
+
for (const line of untracked.stdout.split("\n")) {
|
|
155
|
+
addPath(line);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...out];
|
|
159
|
+
}
|
|
160
|
+
/** Normalize repo-relative paths for exact set membership (always POSIX separators). */
|
|
161
|
+
export function normalizeRepoRelPath(p) {
|
|
162
|
+
return p
|
|
163
|
+
.replace(/\\/g, "/")
|
|
164
|
+
.replace(/^\.\//, "")
|
|
165
|
+
.replace(/\/{2,}/g, "/");
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* True when `rel` appears in the changed-path set under any normalization that
|
|
169
|
+
* Git C-quoting / slash folding may produce (Greptile conf=4 residual).
|
|
170
|
+
*/
|
|
171
|
+
export function changedSetHasPath(changedSet, rel) {
|
|
172
|
+
const candidates = new Set([
|
|
173
|
+
normalizeRepoRelPath(rel),
|
|
174
|
+
normalizeRepoRelPath(unquoteGitPath(rel)),
|
|
175
|
+
rel.replace(/\\/g, "/"),
|
|
176
|
+
]);
|
|
177
|
+
for (const c of candidates) {
|
|
178
|
+
if (changedSet.has(c))
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const want = normalizeRepoRelPath(rel);
|
|
182
|
+
const wantBase = want.split("/").pop() ?? want;
|
|
183
|
+
for (const p of changedSet) {
|
|
184
|
+
const n = normalizeRepoRelPath(p);
|
|
185
|
+
if (candidates.has(n))
|
|
186
|
+
return true;
|
|
187
|
+
// Same leaf under xbrief/active/ (C-quote / escape divergence)
|
|
188
|
+
if (want.includes("xbrief/active/") &&
|
|
189
|
+
n.includes("xbrief/active/") &&
|
|
190
|
+
(n.split("/").pop() ?? n) === wantBase) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
function listActiveXbriefPaths(projectRoot) {
|
|
197
|
+
const activeDir = join(projectRoot, "xbrief", "active");
|
|
198
|
+
if (!existsSync(activeDir))
|
|
199
|
+
return [];
|
|
200
|
+
return readdirSync(activeDir)
|
|
201
|
+
.filter((n) => n.endsWith(".xbrief.json") || n.endsWith(".vbrief.json"))
|
|
202
|
+
.map((n) => `xbrief/active/${n}`);
|
|
203
|
+
}
|
|
204
|
+
function remediationForExpansion() {
|
|
205
|
+
return ("Renew human approval: re-record the approved-scope digest after operator review " +
|
|
206
|
+
"(`task scope:record-approved-scope` / write `.deft/approved-scope/<plan-id>.json` " +
|
|
207
|
+
"with humanApproval stamp). Editing the active xBRIEF alone does not authorize new " +
|
|
208
|
+
"paths (#3145). See content/docs/scope-provenance.md.");
|
|
209
|
+
}
|
|
210
|
+
function configError(message) {
|
|
211
|
+
return { exitCode: 2, findings: [], message };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Pure evaluation of one active xBRIEF against its approved baseline.
|
|
215
|
+
* Exported for unit tests without git.
|
|
216
|
+
*/
|
|
217
|
+
export function evaluateOneScopeProvenance(input) {
|
|
218
|
+
const planId = extractPlanId(input.currentPayload) ?? input.approved?.planId ?? input.xbriefRelPath;
|
|
219
|
+
const currentScope = normalizeFileScope(extractFileScope(input.currentPayload));
|
|
220
|
+
const currentDigest = computeFileScopeDigest(currentScope);
|
|
221
|
+
// Not modified in this change set → nothing to police for self-auth
|
|
222
|
+
if (!input.xbriefModifiedInChangeSet) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
// Renewed human approval present → accept expansion as re-baselined intent
|
|
226
|
+
if (isHumanApprovalStamp(input.renewedHumanApproval ?? null)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (input.approved === null) {
|
|
230
|
+
// Non-empty file_scope without a path-bound approval is expansion risk
|
|
231
|
+
// (includes plan.id renames that drop the prior approval). Hard-fail so the
|
|
232
|
+
// default check path cannot soft-warn past AC "fails until renewed approval".
|
|
233
|
+
// Empty-scope body-only edits may still soft-warn under migration (no enforce).
|
|
234
|
+
const hard = input.enforce || currentScope.length > 0;
|
|
235
|
+
return {
|
|
236
|
+
xbriefRelPath: input.xbriefRelPath,
|
|
237
|
+
planId,
|
|
238
|
+
kind: "active-xbrief-modified-without-digest",
|
|
239
|
+
expandedPaths: currentScope,
|
|
240
|
+
detail: hard
|
|
241
|
+
? "active xBRIEF modified in change set without a path-bound approved-scope digest " +
|
|
242
|
+
"(expansion or plan-id reset not permitted without renewed human approval)"
|
|
243
|
+
: "active xBRIEF modified in change set but no approved-scope digest is recorded",
|
|
244
|
+
remediation: "Record an approved-scope digest at activation for this xBRIEF path. " +
|
|
245
|
+
remediationForExpansion(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// Approved record exists with human stamp and matching digest → ok
|
|
249
|
+
if (input.approved.fileScopeDigest === currentDigest &&
|
|
250
|
+
isHumanApprovalStamp(input.approved.humanApproval)) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const expanded = scopeExpansion(input.approved.fileScope, currentScope);
|
|
254
|
+
if (expanded.length === 0) {
|
|
255
|
+
// Scope shrink or identical after normalize — body edit without expansion is OK
|
|
256
|
+
if (input.approved.fileScopeDigest === currentDigest) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
// Digest mismatch without path expansion (reorder/noise) — still OK for v1
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// Expansion without renewed human approval = self-authorization
|
|
263
|
+
return {
|
|
264
|
+
xbriefRelPath: input.xbriefRelPath,
|
|
265
|
+
planId,
|
|
266
|
+
kind: "self-authorizing-scope-expansion",
|
|
267
|
+
expandedPaths: expanded,
|
|
268
|
+
detail: `active xBRIEF expanded file_scope by ${expanded.length} path(s) in the same change set; ` +
|
|
269
|
+
"modified xBRIEF cannot authorize the new paths",
|
|
270
|
+
remediation: remediationForExpansion(),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Evaluate scope provenance for a project (or pure injected seams).
|
|
275
|
+
*/
|
|
276
|
+
export function evaluateScopeProvenance(projectRoot, options = {}) {
|
|
277
|
+
const root = resolve(projectRoot);
|
|
278
|
+
const enforce = options.enforce ?? false;
|
|
279
|
+
let changed;
|
|
280
|
+
/** Merge-base ref used for changed-file discovery (null when files injected). */
|
|
281
|
+
let discoveryBaseRef = null;
|
|
282
|
+
try {
|
|
283
|
+
if (options.changedFiles !== undefined) {
|
|
284
|
+
changed = [...options.changedFiles].map((p) => p.replace(/\\/g, "/"));
|
|
285
|
+
// Prefer explicit baseRef for base-scope comparisons; do not force git
|
|
286
|
+
// discovery on pure injected-seam tests (cwd may not be a repo).
|
|
287
|
+
if (options.baseRef !== undefined && options.baseRef !== "" && options.baseRef !== "HEAD") {
|
|
288
|
+
discoveryBaseRef = options.baseRef;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
try {
|
|
292
|
+
discoveryBaseRef = resolveDefaultBaseRef(root);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
discoveryBaseRef = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
// Default is PR-aware (origin/master...), not bare HEAD — bare HEAD misses
|
|
301
|
+
// committed PR diffs on clean checkouts (#3145 Greptile P1).
|
|
302
|
+
let baseRef = options.baseRef;
|
|
303
|
+
if (baseRef === undefined || baseRef === "" || baseRef === "HEAD") {
|
|
304
|
+
const resolved = resolveDefaultBaseRef(root);
|
|
305
|
+
if (resolved === null) {
|
|
306
|
+
return configError("verify_scope_provenance: no merge-base ref found (origin/master|main, " +
|
|
307
|
+
"DEFT_BASE_REF, or GITHUB_BASE_REF).\n" +
|
|
308
|
+
" Recovery: fetch the default branch or pass --base-ref <ref>. " +
|
|
309
|
+
"Bare HEAD cannot see committed PR scope expansion (#3145).");
|
|
310
|
+
}
|
|
311
|
+
baseRef = resolved;
|
|
312
|
+
}
|
|
313
|
+
discoveryBaseRef = baseRef;
|
|
314
|
+
changed = changedFilesVsBase(root, baseRef);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err instanceof GitNotFoundError) {
|
|
319
|
+
return configError("verify_scope_provenance: 'git' executable not found on PATH.\n" +
|
|
320
|
+
" Recovery: install git or run inside a git working tree.");
|
|
321
|
+
}
|
|
322
|
+
if (err instanceof GitCommandError) {
|
|
323
|
+
const msg = err.message.toLowerCase();
|
|
324
|
+
// Only non-repo trees skip clean (greenfield smoke). Other git failures
|
|
325
|
+
// fail closed so discovery errors cannot hide scope expansion (Greptile).
|
|
326
|
+
if (msg.includes("not a git repository") ||
|
|
327
|
+
msg.includes("outside repository") ||
|
|
328
|
+
msg.includes("not a git working tree")) {
|
|
329
|
+
return {
|
|
330
|
+
exitCode: 0,
|
|
331
|
+
findings: [],
|
|
332
|
+
message: `verify_scope_provenance: skipped -- not a git working tree (${err.message}). ` +
|
|
333
|
+
"Initialize git or pass --base-ref / inject changedFiles (#3145).",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return configError(`verify_scope_provenance: git failed -- ${err.message}\n` +
|
|
337
|
+
" Recovery: ensure --project-root points at a healthy git working tree.");
|
|
338
|
+
}
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
const changedSet = new Set(changed.map((p) => normalizeRepoRelPath(p)));
|
|
342
|
+
const findings = [];
|
|
343
|
+
const softFindings = [];
|
|
344
|
+
const approvedByPlan = new Map();
|
|
345
|
+
if (options.approvedRecords !== undefined) {
|
|
346
|
+
for (const r of options.approvedRecords) {
|
|
347
|
+
approvedByPlan.set(r.planId, r);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
for (const r of listApprovedScopeRecords(root)) {
|
|
352
|
+
approvedByPlan.set(r.planId, r);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
let activeEntries;
|
|
356
|
+
if (options.activeXbriefs !== undefined) {
|
|
357
|
+
activeEntries = [...options.activeXbriefs.entries()].map(([rel, raw]) => ({
|
|
358
|
+
rel: normalizeRepoRelPath(rel),
|
|
359
|
+
raw,
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
activeEntries = [];
|
|
364
|
+
for (const rel of listActiveXbriefPaths(root)) {
|
|
365
|
+
const full = join(root, rel);
|
|
366
|
+
if (!existsSync(full))
|
|
367
|
+
continue;
|
|
368
|
+
try {
|
|
369
|
+
activeEntries.push({ rel: normalizeRepoRelPath(rel), raw: readFileSync(full, "utf8") });
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// skip unreadable
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const { rel, raw } of activeEntries) {
|
|
377
|
+
let payload;
|
|
378
|
+
try {
|
|
379
|
+
payload = JSON.parse(raw);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const planId = extractPlanId(payload);
|
|
385
|
+
const approvedCandidate = (planId !== null ? approvedByPlan.get(planId) : undefined) ??
|
|
386
|
+
(planId !== null ? readApprovedScopeRecord(root, planId) : null) ??
|
|
387
|
+
null;
|
|
388
|
+
// Bind approval to this xBRIEF path — do not reuse another plan's stamp when
|
|
389
|
+
// plan.id was rewritten to match (Greptile conf=1).
|
|
390
|
+
const approved = approvedCandidate !== null &&
|
|
391
|
+
normalizeRepoRelPath(approvedCandidate.xbriefRelPath) === normalizeRepoRelPath(rel)
|
|
392
|
+
? approvedCandidate
|
|
393
|
+
: null;
|
|
394
|
+
const modified = changedSetHasPath(changedSet, rel);
|
|
395
|
+
// Only an explicit renewed stamp from options (or a pre-existing on-disk
|
|
396
|
+
// digest that was NOT rewritten in this change set) may authorize expansion.
|
|
397
|
+
// Same-PR rewrite of .deft/approved-scope/<id>.json is NOT sufficient
|
|
398
|
+
// (Greptile P1: changed approval record self-authorizes scope).
|
|
399
|
+
const renewed = planId !== null ? (options.renewedApprovals?.get(planId) ?? null) : null;
|
|
400
|
+
const approvalRecordRel = planId !== null
|
|
401
|
+
? `.deft/approved-scope/${planId.replace(/[^a-zA-Z0-9._-]/g, "_")}.json`
|
|
402
|
+
: null;
|
|
403
|
+
const approvalInGitChange = approvalRecordRel !== null &&
|
|
404
|
+
[...changedSet].some((p) => {
|
|
405
|
+
const n = normalizeRepoRelPath(p);
|
|
406
|
+
if (n === approvalRecordRel || n.endsWith(`/${approvalRecordRel}`))
|
|
407
|
+
return true;
|
|
408
|
+
// Exact basename match only — no planId substring (Greptile / SLizard).
|
|
409
|
+
if (planId === null)
|
|
410
|
+
return false;
|
|
411
|
+
const safe = planId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
412
|
+
return (n.includes("/approved-scope/") &&
|
|
413
|
+
(n.endsWith(`/${safe}.json`) || n.endsWith(`${safe}.json`)));
|
|
414
|
+
});
|
|
415
|
+
// Ignored / untracked approved-scope files never appear in git changedSet.
|
|
416
|
+
// Concurrent rewrite attack: scope *expanded* vs base AND an untracked disk
|
|
417
|
+
// approval was rewritten to match the *new* digest. Body-only xBRIEF edits
|
|
418
|
+
// with a pre-existing matching approval must NOT fail (Greptile conf=2).
|
|
419
|
+
let approvalDiskOnly = false;
|
|
420
|
+
if (modified &&
|
|
421
|
+
approved !== null &&
|
|
422
|
+
renewed === null &&
|
|
423
|
+
approvalRecordRel !== null &&
|
|
424
|
+
!approvalInGitChange &&
|
|
425
|
+
existsSync(join(root, approvalRecordRel)) &&
|
|
426
|
+
isHumanApprovalStamp(approved.humanApproval)) {
|
|
427
|
+
const currentDigest = computeFileScopeDigest(normalizeFileScope(extractFileScope(payload)));
|
|
428
|
+
if (approved.fileScopeDigest === currentDigest) {
|
|
429
|
+
const baseRaw = discoveryBaseRef !== null ? readRepoFileAtRef(root, discoveryBaseRef, rel) : null;
|
|
430
|
+
if (baseRaw === null) {
|
|
431
|
+
// New active xBRIEF with untracked matching approval → fail closed.
|
|
432
|
+
approvalDiskOnly = true;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
try {
|
|
436
|
+
const basePayload = JSON.parse(baseRaw);
|
|
437
|
+
const baseDigest = computeFileScopeDigest(normalizeFileScope(extractFileScope(basePayload)));
|
|
438
|
+
// Only concurrent-rewrite when file-scope actually grew/changed.
|
|
439
|
+
if (baseDigest !== currentDigest) {
|
|
440
|
+
approvalDiskOnly = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
approvalDiskOnly = true;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const approvalRecordRewritten = approvalInGitChange || approvalDiskOnly;
|
|
450
|
+
const recordMatchesCurrent = approved !== null &&
|
|
451
|
+
!approvalRecordRewritten &&
|
|
452
|
+
approved.fileScopeDigest ===
|
|
453
|
+
computeFileScopeDigest(normalizeFileScope(extractFileScope(payload))) &&
|
|
454
|
+
isHumanApprovalStamp(approved.humanApproval);
|
|
455
|
+
// Same-PR approval rewrite without independent renewal is hard-fail expansion,
|
|
456
|
+
// not a soft missing-digest migration warning (Greptile P1).
|
|
457
|
+
if (approvalRecordRewritten && modified && renewed === null) {
|
|
458
|
+
const currentScope = normalizeFileScope(extractFileScope(payload));
|
|
459
|
+
findings.push({
|
|
460
|
+
xbriefRelPath: rel,
|
|
461
|
+
planId: planId ?? rel,
|
|
462
|
+
kind: "self-authorizing-scope-expansion",
|
|
463
|
+
expandedPaths: currentScope,
|
|
464
|
+
detail: "approved-scope record rewritten in the same change set as the active xBRIEF; " +
|
|
465
|
+
"cannot self-authorize via concurrent approval rewrite",
|
|
466
|
+
remediation: "Record renewed human approval outside the implementation PR (or pass an " +
|
|
467
|
+
"independent renewed stamp). Same-PR approval rewrites do not authorize expansion (#3145).",
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const finding = evaluateOneScopeProvenance({
|
|
472
|
+
xbriefRelPath: rel,
|
|
473
|
+
currentPayload: payload,
|
|
474
|
+
approved,
|
|
475
|
+
xbriefModifiedInChangeSet: modified,
|
|
476
|
+
enforce,
|
|
477
|
+
renewedHumanApproval: renewed ?? (recordMatchesCurrent ? approved?.humanApproval : null),
|
|
478
|
+
});
|
|
479
|
+
if (finding === null)
|
|
480
|
+
continue;
|
|
481
|
+
// Soft migration: body-only edits (empty file_scope) without digest when !enforce.
|
|
482
|
+
// Non-empty scope without path-bound approval hard-fails (plan-id reset / expansion).
|
|
483
|
+
if (finding.kind === "active-xbrief-modified-without-digest" &&
|
|
484
|
+
!enforce &&
|
|
485
|
+
finding.expandedPaths.length === 0) {
|
|
486
|
+
softFindings.push(finding);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// Self-auth expansion and non-empty scope without digest always hard-fail
|
|
490
|
+
findings.push(finding);
|
|
491
|
+
}
|
|
492
|
+
if (findings.length === 0 && softFindings.length === 0) {
|
|
493
|
+
return {
|
|
494
|
+
exitCode: 0,
|
|
495
|
+
findings: [],
|
|
496
|
+
message: `verify_scope_provenance: clean (${activeEntries.length} active xBRIEF(s), ` +
|
|
497
|
+
`${changed.length} changed file(s)) (#3145).`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (findings.length === 0) {
|
|
501
|
+
// Warn-only migration discoveries
|
|
502
|
+
const body = softFindings
|
|
503
|
+
.map((f) => ` ${f.xbriefRelPath} (${f.planId})\n kind: ${f.kind}\n detail: ${f.detail}`)
|
|
504
|
+
.join("\n");
|
|
505
|
+
return {
|
|
506
|
+
exitCode: 0,
|
|
507
|
+
findings: softFindings,
|
|
508
|
+
message: `verify_scope_provenance: WARN ${softFindings.length} migration finding(s) ` +
|
|
509
|
+
`(not failing; pass --enforce to fail closed) (#3145).\n${body}`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const all = [...findings, ...softFindings];
|
|
513
|
+
const body = all
|
|
514
|
+
.map((f) => ` ${f.xbriefRelPath} (${f.planId})\n` +
|
|
515
|
+
` kind: ${f.kind}\n` +
|
|
516
|
+
` expanded: ${f.expandedPaths.join(", ") || "(none)"}\n` +
|
|
517
|
+
` detail: ${f.detail}\n` +
|
|
518
|
+
` remediation: ${f.remediation}`)
|
|
519
|
+
.join("\n");
|
|
520
|
+
return {
|
|
521
|
+
exitCode: 1,
|
|
522
|
+
findings: all,
|
|
523
|
+
message: `verify_scope_provenance: ${findings.length} self-authorization violation(s) (#3145).\n${body}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/** Re-export builder for activation hook callers. */
|
|
527
|
+
export { buildApprovedScopeRecord, writeApprovedScopeRecord } from "./digest.js";
|
|
528
|
+
//# sourceMappingURL=evaluate.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-provenance package surface (#3145).
|
|
3
|
+
*/
|
|
4
|
+
export { APPROVED_SCOPE_DIR, type ApprovedScopeRecord, approvedScopeDir, approvedScopeRecordPath, buildApprovedScopeRecord, computeFileScopeDigest, computeTextDigest, extractFileScope, extractPlanId, isHumanApprovalStamp, listApprovedScopeRecords, normalizeFileScope, readApprovedScopeRecord, scopeExpansion, writeApprovedScopeRecord, } from "./digest.js";
|
|
5
|
+
export { evaluateOneScopeProvenance, evaluateScopeProvenance, resolveDefaultBaseRef, type ScopeProvenanceFinding, type ScopeProvenanceOptions, type ScopeProvenanceResult, type ScopeProvenanceViolationKind, } from "./evaluate.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope-provenance package surface (#3145).
|
|
3
|
+
*/
|
|
4
|
+
export { APPROVED_SCOPE_DIR, approvedScopeDir, approvedScopeRecordPath, buildApprovedScopeRecord, computeFileScopeDigest, computeTextDigest, extractFileScope, extractPlanId, isHumanApprovalStamp, listApprovedScopeRecords, normalizeFileScope, readApprovedScopeRecord, scopeExpansion, writeApprovedScopeRecord, } from "./digest.js";
|
|
5
|
+
export { evaluateOneScopeProvenance, evaluateScopeProvenance, resolveDefaultBaseRef, } from "./evaluate.js";
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft post-compact / post-amnesia AGENTS re-bind (#3171 / epic #2769 pass-3).
|
|
3
|
+
*
|
|
4
|
+
* Single in-repo source of truth for the soft checklist. File-host compact and
|
|
5
|
+
* SessionStart hooks, Codex best-effort SessionStart, and the OpenClaw durable
|
|
6
|
+
* skill adapter all derive from this module — no divergent host-only rule text.
|
|
7
|
+
*
|
|
8
|
+
* Soft complements hard Tier-1 compact re-arm (#2113). Soft never replaces,
|
|
9
|
+
* weakens, or short-circuits PreToolUse deny / mutation ritual.
|
|
10
|
+
*/
|
|
11
|
+
/** Epic + implement child anchors for docs and wire markers. */
|
|
12
|
+
export declare const SOFT_AGENTS_REBIND_EPIC = 2769;
|
|
13
|
+
export declare const SOFT_AGENTS_REBIND_ISSUE = 3171;
|
|
14
|
+
export declare const SOFT_AGENTS_REBIND_EVIDENCE_ISSUE = 3161;
|
|
15
|
+
/** Stable marker substring present in every host-facing soft deposit. */
|
|
16
|
+
export declare const SOFT_AGENTS_REBIND_MARKER = "Directive soft post-compact AGENTS re-bind (#3171 / #2769)";
|
|
17
|
+
/** Managed OpenClaw skill directory name (doctor/init deposit). */
|
|
18
|
+
export declare const OPENCLAW_SOFT_REBIND_SKILL_ID = "deft-directive-post-compact-rebind";
|
|
19
|
+
/** Sentinel inside managed OpenClaw skill bodies for rewrite detection. */
|
|
20
|
+
export declare const OPENCLAW_SOFT_REBIND_MANAGED_MARKER = "<!-- deft:managed soft-agents-rebind #3171 -->";
|
|
21
|
+
/**
|
|
22
|
+
* Pass-3 §2 checklist obligations — one ordered list, all hosts consume.
|
|
23
|
+
* Host adapters may wrap packaging; they must not invent alternate rule text.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SOFT_AGENTS_REBIND_CHECKLIST: readonly [{
|
|
26
|
+
readonly id: "reread-agents";
|
|
27
|
+
readonly text: string;
|
|
28
|
+
}, {
|
|
29
|
+
readonly id: "confirm-learned";
|
|
30
|
+
readonly text: string;
|
|
31
|
+
}, {
|
|
32
|
+
readonly id: "deposit-integrity";
|
|
33
|
+
readonly text: string;
|
|
34
|
+
}, {
|
|
35
|
+
readonly id: "summary-not-sot";
|
|
36
|
+
readonly text: string;
|
|
37
|
+
}, {
|
|
38
|
+
readonly id: "operational-ask-trap";
|
|
39
|
+
readonly text: string;
|
|
40
|
+
}, {
|
|
41
|
+
readonly id: "mutation-vs-readonly";
|
|
42
|
+
readonly text: string;
|
|
43
|
+
}];
|
|
44
|
+
export type SoftAgentsRebindChecklistId = (typeof SOFT_AGENTS_REBIND_CHECKLIST)[number]["id"];
|
|
45
|
+
/** Per-host soft deposit posture for docs + tests (#3171 matrix). */
|
|
46
|
+
export type SoftRebindHost = "cursor" | "claude" | "grok" | "codex" | "openclaw";
|
|
47
|
+
export interface SoftRebindHostRow {
|
|
48
|
+
readonly host: SoftRebindHost;
|
|
49
|
+
readonly family: string;
|
|
50
|
+
readonly hardCompact: "deposited" | "unsupported";
|
|
51
|
+
readonly softRebind: "required" | "docs-best-effort";
|
|
52
|
+
readonly wire: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Five-row host matrix (Cursor / Claude / Grok / Codex / OpenClaw).
|
|
56
|
+
* Docs and deposit tests must stay aligned with this table.
|
|
57
|
+
*/
|
|
58
|
+
export declare const SOFT_REBIND_HOST_MATRIX: readonly SoftRebindHostRow[];
|
|
59
|
+
/** Forbidden soft wording — soft must never invite skipping mutation ritual. */
|
|
60
|
+
export declare const SOFT_REBIND_FORBIDDEN_PHRASES: readonly ["skip session:start for writes", "skip the mutation ritual", "you may skip session:start", "ritual not required for writes", "soft replaces hard"];
|
|
61
|
+
/**
|
|
62
|
+
* Format the shared soft checklist for host injection / skill bodies.
|
|
63
|
+
*/
|
|
64
|
+
export declare function formatSoftAgentsRebindChecklist(options?: {
|
|
65
|
+
readonly includeHeader?: boolean;
|
|
66
|
+
}): string;
|
|
67
|
+
/**
|
|
68
|
+
* Append soft checklist to a hard compact re-arm (or SessionStart) message.
|
|
69
|
+
* Hard text stays first so existing recoveries remain discoverable.
|
|
70
|
+
*/
|
|
71
|
+
export declare function appendSoftAgentsRebindToMessage(hardMessage: string): string;
|
|
72
|
+
/** True when text carries the soft re-bind deposit (tests + doctor). */
|
|
73
|
+
export declare function isSoftAgentsRebindText(text: string): boolean;
|
|
74
|
+
/** Soft cue events that should inject checklist without requiring a write tool. */
|
|
75
|
+
export declare function isSoftAgentsRebindEvent(event: string): event is "session.start" | "session.compact";
|
|
76
|
+
/**
|
|
77
|
+
* Whether this decision should carry soft injection on the host wire.
|
|
78
|
+
* Disabled / opt-out codes stay hard-message only.
|
|
79
|
+
*/
|
|
80
|
+
export declare function decisionCarriesSoftAgentsRebind(input: {
|
|
81
|
+
readonly event: string;
|
|
82
|
+
readonly code: string;
|
|
83
|
+
}): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Managed OpenClaw skill body generated from the same SoT checklist.
|
|
86
|
+
* Skill-shaped packaging; obligations identical to file-host soft text.
|
|
87
|
+
*/
|
|
88
|
+
export declare function formatOpenClawSoftRebindSkillMarkdown(): string;
|
|
89
|
+
/** True when skill body is the managed #3171 soft re-bind deposit. */
|
|
90
|
+
export declare function isManagedOpenClawSoftRebindSkill(body: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Assert soft text never invites skipping mutation ritual (unit + deposit tests).
|
|
93
|
+
* Returns empty array when clean.
|
|
94
|
+
*/
|
|
95
|
+
export declare function softAgentsRebindForbiddenHits(text: string): string[];
|
|
96
|
+
//# sourceMappingURL=compact-ritual.d.ts.map
|